diff --git a/Cargo.toml b/Cargo.toml index 42a78fd..e539a13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,4 +13,4 @@ resolver = "2" license = "MIT" readme = "README.md" repository = "https://github.com/vidhanio/html-node" - version = "0.2.0" + version = "0.3.0" diff --git a/html-node-core/Cargo.toml b/html-node-core/Cargo.toml index fb99267..a57e764 100644 --- a/html-node-core/Cargo.toml +++ b/html-node-core/Cargo.toml @@ -23,6 +23,7 @@ html-escape = "0.2" paste = "1.0.14" [features] -axum = ["dep:axum"] -typed = [] -serde = ["dep:serde"] +axum = ["dep:axum"] +pretty = [] +serde = ["dep:serde"] +typed = [] diff --git a/html-node-core/src/http.rs b/html-node-core/src/http.rs index 1ef4132..8cd1d6c 100644 --- a/html-node-core/src/http.rs +++ b/html-node-core/src/http.rs @@ -2,6 +2,8 @@ mod axum { use axum::response::{Html, IntoResponse, Response}; + #[cfg(feature = "pretty")] + use crate::pretty::Pretty; use crate::Node; impl IntoResponse for Node { @@ -9,4 +11,11 @@ mod axum { Html(self.to_string()).into_response() } } + + #[cfg(feature = "pretty")] + impl IntoResponse for Pretty { + fn into_response(self) -> Response { + Html(self.to_string()).into_response() + } + } } diff --git a/html-node-core/src/lib.rs b/html-node-core/src/lib.rs index 212da30..f0ef793 100644 --- a/html-node-core/src/lib.rs +++ b/html-node-core/src/lib.rs @@ -8,14 +8,23 @@ #![warn(missing_docs)] #![cfg_attr(docsrs, feature(doc_auto_cfg))] +/// HTTP Server integrations. mod http; -#[allow(missing_docs)] +/// [`crate::Node`] variant definitions. +mod node; + +/// Pretty printing utilities. +#[cfg(feature = "pretty")] +pub mod pretty; + +/// Typed HTML Nodes. #[cfg(feature = "typed")] pub mod typed; use std::fmt::{self, Display, Formatter}; +pub use self::node::*; #[cfg(feature = "typed")] use self::typed::TypedElement; @@ -84,17 +93,18 @@ impl Node { pub fn from_typed(element: E, children: Option>) -> Self { element.into_node(children) } + + /// Wrap the node in a pretty-printing wrapper. + #[cfg(feature = "pretty")] + #[must_use] + pub fn pretty(self) -> pretty::Pretty { + self.into() + } } -impl From for Node -where - I: IntoIterator, - N: Into, -{ - fn from(iter: I) -> Self { - Self::Fragment(Fragment { - children: iter.into_iter().map(Into::into).collect(), - }) +impl Default for Node { + fn default() -> Self { + Self::EMPTY } } @@ -111,226 +121,48 @@ impl Display for Node { } } -impl Default for Node { - fn default() -> Self { - Self::EMPTY +impl From for Node +where + I: IntoIterator, + N: Into, +{ + fn from(iter: I) -> Self { + Self::Fragment(iter.into()) } } -/// A comment. -/// -/// ```html -/// -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Comment { - /// The text of the comment. - /// - /// ```html - /// - /// ``` - pub comment: String, -} - -impl Display for Comment { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "", self.comment) +impl From for Node { + fn from(comment: Comment) -> Self { + Self::Comment(comment) } } -/// A doctype. -/// -/// ```html -/// -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Doctype { - /// The value of the doctype. - /// - /// ```html - /// - /// ``` - pub syntax: String, -} - -impl Display for Doctype { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "", self.syntax) +impl From for Node { + fn from(doctype: Doctype) -> Self { + Self::Doctype(doctype) } } -/// A fragment. -/// -/// ```html -/// <> -/// I'm in a fragment! -/// -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Fragment { - /// The children of the fragment. - /// - /// ```html - /// <> - /// - /// I'm another child! - /// - pub children: Vec, -} - -impl Display for Fragment { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write_children(f, &self.children, true) +impl From for Node { + fn from(fragment: Fragment) -> Self { + Self::Fragment(fragment) } } -/// An element. -/// -/// ```html -///
-/// I'm in an element! -///
-/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Element { - /// The name of the element. - /// - /// ```html - /// - /// ``` - pub name: String, - - /// The attributes of the element. - /// - /// ```html - ///
- /// ``` - pub attributes: Vec<(String, Option)>, - - /// The children of the element. - /// - /// ```html - ///
- /// - /// I'm another child! - ///
- /// ``` - pub children: Option>, -} - -impl Element { - /// Create a new [`Element`] from a [`TypedElement`]. - #[cfg(feature = "typed")] - pub fn from_typed(element: E, children: Option>) -> Self { - element.into_element(children) - } -} - -impl Display for Element { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "<{}", self.name)?; - - for (key, value) in &self.attributes { - write!(f, " {key}")?; - - if let Some(value) = value { - let encoded_value = html_escape::encode_double_quoted_attribute(value); - write!(f, r#"="{encoded_value}""#)?; - } - } - write!(f, ">")?; - - if let Some(children) = &self.children { - write_children(f, children, false)?; - - write!(f, "", self.name)?; - }; - - Ok(()) - } -} - -/// A text node. -/// -/// ```html -///
-/// I'm a text node! -///
-#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Text { - /// The text of the node. - /// - /// ```html - ///
- /// text - ///
- pub text: String, -} - -impl Display for Text { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let encoded_value = html_escape::encode_text_minimal(&self.text); - write!(f, "{encoded_value}") +impl From for Node { + fn from(element: Element) -> Self { + Self::Element(element) } } -/// An unsafe text node. -/// -/// # Warning -/// -/// [`UnsafeText`] is not escaped when rendered, and as such, can allow -/// for XSS attacks. Use with caution! -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct UnsafeText { - /// The text of the node. - pub text: String, -} - -impl Display for UnsafeText { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.text) +impl From for Node { + fn from(text: Text) -> Self { + Self::Text(text) } } -/// Writes the children of a node. -/// -/// If the formatter is in alternate mode, then the children are put on their -/// own lines. -/// -/// If alternate mode is enabled and `is_fragment` is false, then each line -/// is indented by 4 spaces. -fn write_children(f: &mut Formatter<'_>, children: &[Node], is_fragment: bool) -> fmt::Result { - if f.alternate() { - let mut children_iter = children.iter(); - - if is_fragment { - if let Some(first_child) = children_iter.next() { - write!(f, "{first_child:#}")?; - - for child in children_iter { - write!(f, "\n{child:#}")?; - } - } - } else { - for child_str in children_iter.map(|child| format!("{child:#}")) { - for line in child_str.lines() { - write!(f, "\n {line}")?; - } - } - - // exit inner block - writeln!(f)?; - } - } else { - for child in children { - child.fmt(f)?; - } +impl From for Node { + fn from(text: UnsafeText) -> Self { + Self::UnsafeText(text) } - Ok(()) } diff --git a/html-node-core/src/node.rs b/html-node-core/src/node.rs new file mode 100644 index 0000000..b347340 --- /dev/null +++ b/html-node-core/src/node.rs @@ -0,0 +1,51 @@ +use std::fmt::{self, Display, Formatter}; + +mod comment; +mod doctype; +mod element; +mod fragment; +mod text; +mod unsafe_text; + +pub use self::{ + comment::Comment, doctype::Doctype, element::Element, fragment::Fragment, text::Text, + unsafe_text::UnsafeText, +}; +use crate::Node; + +/// Writes the children of a node. +/// +/// If the formatter is in alternate mode, then the children are put on their +/// own lines. +/// +/// If alternate mode is enabled and `is_fragment` is false, then each line +/// is indented by 4 spaces. +fn write_children(f: &mut Formatter<'_>, children: &[Node], is_fragment: bool) -> fmt::Result { + if f.alternate() { + let mut children_iter = children.iter(); + + if is_fragment { + if let Some(first_child) = children_iter.next() { + write!(f, "{first_child:#}")?; + + for child in children_iter { + write!(f, "\n{child:#}")?; + } + } + } else { + for child_str in children_iter.map(|child| format!("{child:#}")) { + for line in child_str.lines() { + write!(f, "\n {line}")?; + } + } + + // exit inner block + writeln!(f)?; + } + } else { + for child in children { + child.fmt(f)?; + } + } + Ok(()) +} diff --git a/html-node-core/src/node/comment.rs b/html-node-core/src/node/comment.rs new file mode 100644 index 0000000..cae3bed --- /dev/null +++ b/html-node-core/src/node/comment.rs @@ -0,0 +1,36 @@ +use std::fmt::{self, Display, Formatter}; + +/// A comment. +/// +/// ```html +/// +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Comment { + /// The text of the comment. + /// + /// ```html + /// + /// ``` + pub comment: String, +} + +impl Display for Comment { + /// Format as an HTML comment. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "", self.comment) + } +} + +impl From for Comment +where + C: Into, +{ + /// Create a new comment from anything that can be converted into a string. + fn from(comment: C) -> Self { + Self { + comment: comment.into(), + } + } +} diff --git a/html-node-core/src/node/doctype.rs b/html-node-core/src/node/doctype.rs new file mode 100644 index 0000000..11477b5 --- /dev/null +++ b/html-node-core/src/node/doctype.rs @@ -0,0 +1,37 @@ +use std::fmt::{self, Display, Formatter}; + +/// A doctype. +/// +/// ```html +/// +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Doctype { + /// The value of the doctype. + /// + /// ```html + /// + /// ``` + pub syntax: String, +} + +impl Display for Doctype { + /// Format as an HTML doctype element. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "", self.syntax) + } +} + +impl From for Doctype +where + S: Into, +{ + /// Create a new doctype element with a syntax attribute set + /// from anything that can be converted into a string. + fn from(syntax: S) -> Self { + Self { + syntax: syntax.into(), + } + } +} diff --git a/html-node-core/src/node/element.rs b/html-node-core/src/node/element.rs new file mode 100644 index 0000000..8d9656d --- /dev/null +++ b/html-node-core/src/node/element.rs @@ -0,0 +1,90 @@ +use std::fmt::{self, Display, Formatter}; + +use super::write_children; +#[cfg(feature = "typed")] +use crate::typed::TypedElement; +use crate::Node; + +/// An element. +/// +/// ```html +///
+/// I'm in an element! +///
+/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Element { + /// The name of the element. + /// + /// ```html + /// + /// ``` + pub name: String, + + /// The attributes of the element. + /// + /// ```html + ///
+ /// ``` + pub attributes: Vec<(String, Option)>, + + /// The children of the element. + /// + /// ```html + ///
+ /// + /// I'm another child! + ///
+ /// ``` + pub children: Option>, +} + +#[cfg(feature = "typed")] +impl Element { + /// Create a new [`Element`] from a [`TypedElement`]. + pub fn from_typed(element: E, children: Option>) -> Self { + element.into_element(children) + } +} + +impl Display for Element { + /// Format as an HTML element. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "<{}", self.name)?; + + for (key, value) in &self.attributes { + write!(f, " {key}")?; + + if let Some(value) = value { + let encoded_value = html_escape::encode_double_quoted_attribute(value); + write!(f, r#"="{encoded_value}""#)?; + } + } + write!(f, ">")?; + + if let Some(children) = &self.children { + write_children(f, children, false)?; + + write!(f, "", self.name)?; + }; + + Ok(()) + } +} + +impl From for Element +where + N: Into, +{ + /// Create an HTML element directly from a string. + /// + /// This [`Element`] has no attributes and no children. + fn from(name: N) -> Self { + Self { + name: name.into(), + attributes: Vec::new(), + children: None, + } + } +} diff --git a/html-node-core/src/node/fragment.rs b/html-node-core/src/node/fragment.rs new file mode 100644 index 0000000..c826902 --- /dev/null +++ b/html-node-core/src/node/fragment.rs @@ -0,0 +1,59 @@ +use std::fmt::{self, Display, Formatter}; + +use super::write_children; +use crate::Node; + +/// A fragment. +/// +/// ```html +/// <> +/// I'm in a fragment! +/// +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Fragment { + /// The children of the fragment. + /// + /// ```html + /// <> + /// + /// I'm another child! + /// + pub children: Vec, +} + +impl Display for Fragment { + /// Format the fragment's childrent as HTML elements. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write_children(f, &self.children, true) + } +} + +impl FromIterator for Fragment +where + N: Into, +{ + /// Create a new fragment from an iterator of anything that + /// can be converted into a [`crate::Node`]. + fn from_iter(iter: I) -> Self + where + I: IntoIterator, + { + Self { + children: iter.into_iter().map(Into::into).collect(), + } + } +} + +impl From for Fragment +where + I: IntoIterator, + N: Into, +{ + /// Create a new fragment from any iterator of anything that + /// can be converted into a [`crate::Node`]. + fn from(iter: I) -> Self { + Self::from_iter(iter) + } +} diff --git a/html-node-core/src/node/text.rs b/html-node-core/src/node/text.rs new file mode 100644 index 0000000..822496b --- /dev/null +++ b/html-node-core/src/node/text.rs @@ -0,0 +1,38 @@ +use std::fmt::{self, Display, Formatter}; + +/// A text node. +/// +/// ```html +///
+/// I'm a text node! +///
+#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Text { + /// The text of the node. + /// + /// ```html + ///
+ /// text + ///
+ pub text: String, +} + +impl Display for Text { + /// Format as HTML encoded string. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let encoded_value = html_escape::encode_text_minimal(&self.text); + write!(f, "{encoded_value}") + } +} + +impl From for Text +where + T: Into, +{ + /// Create a new text element from anything that can + /// be converted into a string. + fn from(text: T) -> Self { + Self { text: text.into() } + } +} diff --git a/html-node-core/src/node/unsafe_text.rs b/html-node-core/src/node/unsafe_text.rs new file mode 100644 index 0000000..da39eea --- /dev/null +++ b/html-node-core/src/node/unsafe_text.rs @@ -0,0 +1,34 @@ +use std::fmt::{self, Display, Formatter}; + +/// An unsafe text node. +/// +/// # Warning +/// +/// [`UnsafeText`] is not escaped when rendered, and as such, can allow +/// for XSS attacks. Use with caution! +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct UnsafeText { + /// The text of the node. + pub text: String, +} + +impl Display for UnsafeText { + /// Unescaped text. + /// + /// This string is **not** HTML encoded! + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.text) + } +} + +impl From for UnsafeText +where + T: Into, +{ + /// Create a new unsafe text element from anything + /// that can be converted into a string. + fn from(text: T) -> Self { + Self { text: text.into() } + } +} diff --git a/html-node-core/src/pretty.rs b/html-node-core/src/pretty.rs new file mode 100644 index 0000000..5f45a77 --- /dev/null +++ b/html-node-core/src/pretty.rs @@ -0,0 +1,25 @@ +use std::fmt::{self, Display, Formatter}; + +use crate::Node; + +/// A wrapper around [`Node`] that is always pretty printed. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Pretty(pub Node); + +impl Display for Pretty { + /// Format as a pretty printed HTML node. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{:#}", self.0) + } +} + +impl From for Pretty +where + N: Into, +{ + /// Create a new pretty wrapper around the given node. + fn from(node: N) -> Self { + Self(node.into()) + } +} diff --git a/html-node-core/src/typed/mod.rs b/html-node-core/src/typed.rs similarity index 100% rename from html-node-core/src/typed/mod.rs rename to html-node-core/src/typed.rs diff --git a/html-node/Cargo.toml b/html-node/Cargo.toml index 9114475..d7fe0cf 100644 --- a/html-node/Cargo.toml +++ b/html-node/Cargo.toml @@ -26,8 +26,8 @@ name = "typed_custom_attributes" required-features = ["typed"] [dependencies] -html-node-core = { version = "0.2", path = "../html-node-core" } -html-node-macro = { version = "0.2", path = "../html-node-macro" } +html-node-core = { path = "../html-node-core" } +html-node-macro = { path = "../html-node-macro" } [dev-dependencies] @@ -36,5 +36,6 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [features] axum = ["html-node-core/axum"] +pretty = ["html-node-core/pretty"] serde = ["html-node-core/serde"] typed = ["html-node-core/typed", "html-node-macro/typed"] diff --git a/html-node/examples/axum.rs b/html-node/examples/axum.rs index acbf4dc..8098fd7 100644 --- a/html-node/examples/axum.rs +++ b/html-node/examples/axum.rs @@ -5,6 +5,7 @@ use std::{ use axum::{extract::Query, routing::get, Router, Server}; use html_node::{html, text, Node}; +use html_node_core::pretty::Pretty; #[tokio::main] async fn main() { @@ -24,6 +25,7 @@ fn router() -> Router { .route("/about", get(about)) .route("/contact", get(contact)) .route("/greet", get(greet)) + .route("/pretty", get(pretty)) } fn layout(content: Node) -> Node { @@ -90,3 +92,11 @@ async fn greet(Query(params): Query>) -> Node {

{text!("hello, {name}")}!

}) } + +async fn pretty() -> Pretty { + Pretty(layout(html! { +
+

Pretty

+
+ })) +} diff --git a/html-node/src/lib.rs b/html-node/src/lib.rs index 8441711..979019e 100644 --- a/html-node/src/lib.rs +++ b/html-node/src/lib.rs @@ -67,6 +67,13 @@ //! //! ## Pretty-Printing //! +//! Pretty-printing is supported by default when formatting a [`Node`] using the +//! alternate formatter, specified by a `#` in the format string. +//! +//! If you want to avoid specifying the alternate formatter, enabling the +//! `pretty` feature will provide a convenience method [`Node::pretty()`] that +//! returns a wrapper around the node that will always be pretty-printed. +//! //! ```rust //! use html_node::{html, text}; //! @@ -100,10 +107,25 @@ //!
\ //! "; //! -//! // note the `#` in the format string, which enables pretty-printing +//! // Note the `#` in the format string, which enables pretty-printing //! let formatted_html = format!("{html:#}"); //! //! assert_eq!(formatted_html, expected); +//! +//! # #[cfg(feature = "pretty")] +//! # { +//! // Wrap the HTML node in a pretty-printing wrapper. +//! let pretty = html.pretty(); +//! +//! // Get the pretty-printed HTML as a string by invoking the [`Display`][std::fmt::Display] trait. +//! let pretty_html_string = pretty.to_string(); +//! // Note the '#' is not required here. +//! let pretty_html_format = format!("{pretty}"); +//! +//! assert_eq!(pretty_html_string, expected); +//! assert_eq!(pretty_html_format, expected); +//! assert_eq!(pretty_html_string, pretty_html_format); +//! # } //! ``` #![warn(clippy::cargo)] @@ -118,6 +140,8 @@ mod macros; #[cfg(feature = "typed")] pub mod typed; +#[cfg(feature = "pretty")] +pub use html_node_core::pretty; pub use html_node_core::{Comment, Doctype, Element, Fragment, Node, Text, UnsafeText}; /// The HTML to [`Node`] macro. /// diff --git a/html-node/tests/main.rs b/html-node/tests/main.rs index a6dc763..70a0ec9 100644 --- a/html-node/tests/main.rs +++ b/html-node/tests/main.rs @@ -42,7 +42,7 @@ fn basic() { } #[test] -fn pretty_printed() { +fn pretty_printed_format() { let shopping_list = vec!["milk", "eggs", "bread"]; let html = html! { @@ -93,3 +93,34 @@ fn pretty_printed() { assert_eq!(pretty_html, expected); } + +#[cfg(feature = "pretty")] +#[test] +fn pretty_printed_helper() { + let pretty_html = html! { +
+
+

Pretty Printing Wrapper Test

+

This test should be pretty printed!

+
+
+ } + .pretty(); + + println!("Pretty helper:\n{pretty_html}"); + + let expected = r#"
+
+

+ Pretty Printing Wrapper Test +

+

+ This test should be + + pretty printed! + +

+
+
"#; + assert_eq!(expected, pretty_html.to_string()); +}