Last active
November 19, 2021 09:10
-
-
Save Mathspy/4df19d411a1eaa5a7f0a16d1d19bd967 to your computer and use it in GitHub Desktop.
Zero allocation HTML renderer with escaping
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#![no_std] | |
extern crate alloc; | |
use alloc::borrow::Cow; | |
use core::iter; | |
enum Tag { | |
/// A non-HTML tag that renders into nothing for wrapping text | |
Fragment, | |
Div, | |
Strong, | |
Em, | |
P, | |
Span, | |
} | |
impl Tag { | |
fn starting(&self) -> &'static str { | |
match self { | |
Tag::Fragment => "", | |
Tag::Div => "<div", | |
Tag::Strong => "<strong", | |
Tag::Em => "<em", | |
Tag::P => "<p", | |
Tag::Span => "<span", | |
} | |
} | |
fn ending(&self) -> &'static str { | |
match self { | |
Tag::Fragment => "", | |
Tag::Div => "</div>", | |
Tag::Strong => "</strong>", | |
Tag::Em => "</em>", | |
Tag::P => "</p>", | |
Tag::Span => "</span>", | |
} | |
} | |
} | |
pub struct Wrapper<I, T> | |
where | |
I: Iterator<Item = T>, | |
{ | |
before: Option<T>, | |
wrapped: I, | |
after: Option<T>, | |
} | |
impl<I, T> Iterator for Wrapper<I, T> | |
where | |
I: Iterator<Item = T>, | |
{ | |
type Item = T; | |
fn next(&mut self) -> Option<Self::Item> { | |
if self.before.is_some() { | |
return self.before.take(); | |
} | |
if let Some(item) = self.wrapped.next() { | |
return Some(item); | |
} | |
self.after.take() | |
} | |
} | |
pub trait IntoHtml { | |
const ESCAPED: bool = false; | |
type HtmlIter: Iterator<Item = Cow<'static, str>>; | |
fn into_html(self) -> Self::HtmlIter; | |
} | |
impl<T> IntoHtml for T | |
where | |
T: IntoIterator<Item = Cow<'static, str>>, | |
{ | |
const ESCAPED: bool = false; | |
type HtmlIter = T::IntoIter; | |
fn into_html(self) -> Self::HtmlIter { | |
self.into_iter() | |
} | |
} | |
pub trait IsEscaped: IntoHtml { | |
const CHECK: (); | |
} | |
impl<T: IntoHtml + ?Sized> IsEscaped for T { | |
const CHECK: () = [()][(Self::ESCAPED == true) as usize]; | |
} | |
pub trait IntoAttributes { | |
const ESCAPED: bool = false; | |
type AttributeIter: Iterator<Item = (Cow<'static, str>, Cow<'static, str>)>; | |
fn into_attributes(self) -> Self::AttributeIter; | |
} | |
pub struct ConvertAttributes<I> { | |
iter: I, | |
} | |
impl<I, T> Iterator for ConvertAttributes<I> | |
where | |
I: Iterator<Item = (T, T)>, | |
T: Into<Cow<'static, str>>, | |
{ | |
type Item = (Cow<'static, str>, Cow<'static, str>); | |
fn next(&mut self) -> Option<Self::Item> { | |
if let Some((a, v)) = self.iter.next() { | |
return Some((a.into(), v.into())); | |
} else { | |
return None; | |
} | |
} | |
} | |
impl<I, T> IntoAttributes for I | |
where | |
I: IntoIterator<Item = (T, T)>, | |
T: Into<Cow<'static, str>>, | |
{ | |
const ESCAPED: bool = false; | |
type AttributeIter = ConvertAttributes<I::IntoIter>; | |
fn into_attributes(self) -> Self::AttributeIter { | |
ConvertAttributes { | |
iter: self.into_iter(), | |
} | |
} | |
} | |
#[inline] | |
fn escape_text(text: &'static str) -> impl IntoHtml { | |
text.char_indices() | |
.map(|(index, c)| match c { | |
'&' => "&", | |
'<' => "<", | |
'>' => ">", | |
x => text.get(index..index + x.len_utf8()).unwrap(), | |
}) | |
.map(Cow::from) | |
} | |
pub struct HtmlTag<C, A> { | |
tag: Tag, | |
children: C, | |
attributes: A, | |
} | |
impl HtmlTag<iter::Empty<Cow<'static, str>>, iter::Empty<(Cow<'static, str>, Cow<'static, str>)>> { | |
pub fn text( | |
text: &'static str, | |
) -> HtmlTag<impl IntoHtml, iter::Empty<(Cow<'static, str>, Cow<'static, str>)>> { | |
HtmlTag { | |
tag: Tag::Fragment, | |
children: escape_text(text), | |
attributes: iter::empty(), | |
} | |
} | |
pub fn escaped_unchecked( | |
text: &'static str, | |
) -> HtmlTag<impl IntoHtml, iter::Empty<(Cow<'static, str>, Cow<'static, str>)>> { | |
HtmlTag { | |
tag: Tag::Fragment, | |
children: iter::once(Cow::from(text)), | |
attributes: iter::empty(), | |
} | |
} | |
pub fn div() -> Self { | |
HtmlTag { | |
tag: Tag::Div, | |
children: iter::empty(), | |
attributes: iter::empty(), | |
} | |
} | |
pub fn strong() -> Self { | |
HtmlTag { | |
tag: Tag::Strong, | |
children: iter::empty(), | |
attributes: iter::empty(), | |
} | |
} | |
pub fn p() -> Self { | |
HtmlTag { | |
tag: Tag::P, | |
children: iter::empty(), | |
attributes: iter::empty(), | |
} | |
} | |
pub fn em() -> Self { | |
HtmlTag { | |
tag: Tag::Em, | |
children: iter::empty(), | |
attributes: iter::empty(), | |
} | |
} | |
pub fn span() -> Self { | |
HtmlTag { | |
tag: Tag::Span, | |
children: iter::empty(), | |
attributes: iter::empty(), | |
} | |
} | |
} | |
impl<C, A> HtmlTag<C, A> | |
where | |
C: IntoHtml, | |
{ | |
// ) -> HtmlTag<iter::Chain<C::IntoIter, iter::Once<&'static str>>, A> { | |
pub fn append_text(self, text: &'static str) -> HtmlTag<impl IntoHtml, A> { | |
HtmlTag { | |
tag: self.tag, | |
children: self | |
.children | |
.into_html() | |
.chain(escape_text(text).into_html()), | |
attributes: self.attributes, | |
} | |
} | |
// ) -> HtmlTag<iter::Chain<C::IntoIter, C2::IntoIter>, A> | |
pub fn append_child<C2>(self, child: C2) -> HtmlTag<impl IntoHtml, A> | |
where | |
C2: IsEscaped, | |
{ | |
HtmlTag { | |
tag: self.tag, | |
children: self.children.into_html().chain(child.into_html()), | |
attributes: self.attributes, | |
} | |
} | |
} | |
impl<C, A> HtmlTag<C, A> { | |
pub fn with_children<C2, C3>(self, children: C2) -> HtmlTag<impl IntoHtml, A> | |
where | |
C2: IntoIterator<Item = C3>, | |
C3: IsEscaped, | |
{ | |
HtmlTag { | |
tag: self.tag, | |
children: children | |
.into_iter() | |
.map(|child| child.into_html()) | |
.flatten(), | |
attributes: self.attributes, | |
} | |
} | |
// TODO: Needs escaping | |
pub fn with_attributes<A2>(self, attributes: A2) -> HtmlTag<C, A2> | |
where | |
A2: IntoAttributes, | |
{ | |
HtmlTag { | |
tag: self.tag, | |
children: self.children, | |
attributes, | |
} | |
} | |
} | |
enum AttributeRenderingSteps { | |
Start, | |
RenderedSpace(Cow<'static, str>, Cow<'static, str>), | |
RenderedName(Cow<'static, str>), | |
RenderedStartQuote(Cow<'static, str>), | |
RenderedValue, | |
} | |
impl Default for AttributeRenderingSteps { | |
fn default() -> Self { | |
AttributeRenderingSteps::Start | |
} | |
} | |
pub struct Attributes<I> { | |
iter: I, | |
step: AttributeRenderingSteps, | |
} | |
impl<I> Iterator for Attributes<I> | |
where | |
I: Iterator<Item = (Cow<'static, str>, Cow<'static, str>)>, | |
{ | |
type Item = Cow<'static, str>; | |
fn next(&mut self) -> Option<Self::Item> { | |
let current_step = core::mem::take(&mut self.step); | |
match current_step { | |
AttributeRenderingSteps::Start => { | |
if let Some((attribute, value)) = self.iter.next() { | |
self.step = AttributeRenderingSteps::RenderedSpace(attribute, value); | |
return Some(Cow::from(" ")); | |
} | |
return None; | |
} | |
AttributeRenderingSteps::RenderedSpace(attribute, value) => { | |
self.step = AttributeRenderingSteps::RenderedName(value); | |
return Some(attribute); | |
} | |
AttributeRenderingSteps::RenderedName(value) => { | |
self.step = AttributeRenderingSteps::RenderedStartQuote(value); | |
return Some(Cow::from("=\"")); | |
} | |
AttributeRenderingSteps::RenderedStartQuote(value) => { | |
self.step = AttributeRenderingSteps::RenderedValue; | |
return Some(value); | |
} | |
AttributeRenderingSteps::RenderedValue => { | |
self.step = AttributeRenderingSteps::Start; | |
return Some(Cow::from("\"")); | |
} | |
} | |
} | |
} | |
impl<C, A> IntoHtml for HtmlTag<C, A> | |
where | |
C: IntoHtml, | |
A: IntoAttributes, | |
{ | |
const ESCAPED: bool = true; | |
type HtmlIter = Wrapper< | |
core::iter::Chain< | |
Attributes<A::AttributeIter>, | |
core::iter::Chain<core::iter::Once<Cow<'static, str>>, C::HtmlIter>, | |
>, | |
Cow<'static, str>, | |
>; | |
fn into_html(self) -> Self::HtmlIter { | |
let attributes = Attributes { | |
iter: self.attributes.into_attributes(), | |
step: AttributeRenderingSteps::Start, | |
}; | |
let children = match self.tag { | |
Tag::Fragment => iter::once(Cow::from("")).chain(self.children.into_html()), | |
_ => iter::once(Cow::from(">")).chain(self.children.into_html()), | |
}; | |
Wrapper { | |
before: Some(Cow::from(self.tag.starting())), | |
wrapped: attributes.chain(children), | |
after: Some(Cow::from(self.tag.ending())), | |
} | |
} | |
} | |
#[cfg(test)] | |
mod tests { | |
extern crate std; | |
use super::{HtmlTag, IntoHtml}; | |
use std::string::String; | |
#[test] | |
fn renders_plain_tags() { | |
let html = HtmlTag::div(); | |
assert_eq!( | |
html.into_html().collect::<String>(), | |
String::from("<div></div>") | |
); | |
} | |
#[test] | |
fn renders_with_string_children() { | |
let html = HtmlTag::div().append_text("abc"); | |
assert_eq!( | |
html.into_html().collect::<String>(), | |
String::from("<div>abc</div>") | |
); | |
} | |
#[test] | |
fn renders_with_nested_tags() { | |
let html = HtmlTag::div().with_children([HtmlTag::div()]); | |
assert_eq!( | |
html.into_html().collect::<String>(), | |
String::from("<div><div></div></div>") | |
); | |
} | |
#[test] | |
fn renders_with_appended_children() { | |
let html = HtmlTag::strong() | |
.append_child(HtmlTag::text("STRONG! ")) | |
.append_child(HtmlTag::em().append_text("and sweet")) | |
.append_text(" but STRONG!"); | |
assert_eq!( | |
html.into_html().collect::<String>(), | |
String::from("<strong>STRONG! <em>and sweet</em> but STRONG!</strong>") | |
); | |
} | |
#[test] | |
fn complex_structure() { | |
let html = HtmlTag::p().with_children([HtmlTag::strong() | |
.append_child(HtmlTag::text("You can also nest ")) | |
.append_child(HtmlTag::em().append_text("italic with")) | |
.append_child(HtmlTag::text(" bold"))]); | |
assert_eq!( | |
html.into_html().collect::<String>(), | |
String::from("<p><strong>You can also nest <em>italic with</em> bold</strong></p>") | |
); | |
} | |
#[test] | |
fn complex_structure_with_attributes() { | |
let html = HtmlTag::p() | |
.with_children([HtmlTag::strong() | |
.append_text("You can also nest ") | |
.append_child(HtmlTag::em().append_text("italic with")) | |
.append_text(" bold")]) | |
.with_attributes([("class", "red"), ("id", "meh")]); | |
assert_eq!( | |
html.into_html().collect::<String>(), | |
String::from( | |
"<p class=\"red\" id=\"meh\"><strong>You can also nest <em>italic with</em> bold</strong></p>" | |
) | |
); | |
} | |
#[test] | |
fn it_composes() { | |
fn composition() -> impl IntoHtml { | |
HtmlTag::p() | |
.with_children([HtmlTag::strong() | |
.append_text("You can also nest ") | |
.append_child(HtmlTag::em().append_text("italic with")) | |
.append_text(" bold")]) | |
.with_attributes([("class", "red"), ("id", "meh")]) | |
} | |
fn is_great() -> impl IntoHtml { | |
HtmlTag::div().with_children((1..3).map(|_| composition())) | |
} | |
assert_eq!( | |
is_great().into_html().collect::<String>(), | |
String::from( | |
"<div><p class=\"red\" id=\"meh\"><strong>You can also nest <em>italic with</em> bold</strong></p><p class=\"red\" id=\"meh\"><strong>You can also nest <em>italic with</em> bold</strong></p></div>" | |
) | |
) | |
} | |
#[test] | |
fn it_does_not_escape_unchecked_escapes() { | |
let other_things_get_escaped = | |
HtmlTag::p().append_child(HtmlTag::escaped_unchecked("<escape me!>")); | |
assert_eq!( | |
other_things_get_escaped.into_html().collect::<String>(), | |
String::from("<p><escape me!></p>") | |
) | |
} | |
// #[test] | |
// fn it_escapes() { | |
// let html_tags_do_not_get_escaped = HtmlTag::p().append_child(HtmlTag::div()); | |
// assert_eq!( | |
// html_tags_do_not_get_escaped.into_html().collect::<String>(), | |
// String::from("<p><div></div></p>") | |
// ); | |
// let other_things_get_escaped = HtmlTag::p().append_child(std::iter::once("<escape me!>")); | |
// assert_eq!( | |
// other_things_get_escaped.into_html().collect::<String>(), | |
// String::from("<p><escape me!></p>") | |
// ) | |
// } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
So one way to avoid having to escape except text is to constraint what needs escaping like I have done in v3
The fourth point is still a problem but it's a problem only because we need to do escaping. Ideally if we have a
String
we should be able to return references from it, but even withLendingIterator
s this won't fit with our current implementation because theCow
s won't be'static
coming from aString
.One way to "dodge" the fourth point at the price of some extra allocation is: in case of
&'static str
do what we did in v2 escaping. In case of aString
, allocate a newString
with the characters escaped. This fits the current trait signature but is not ideal.So yeah I am going to pause work on this and maybe come back to it one day after
LendingIterator
s are stable and see what I can do about it!