Skip to content

Instantly share code, notes, and snippets.

@Mathspy
Last active November 19, 2021 09:10
Show Gist options
  • Save Mathspy/4df19d411a1eaa5a7f0a16d1d19bd967 to your computer and use it in GitHub Desktop.
Save Mathspy/4df19d411a1eaa5a7f0a16d1d19bd967 to your computer and use it in GitHub Desktop.
Zero allocation HTML renderer with escaping
#![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 {
'&' => "&amp;",
'<' => "&lt;",
'>' => "&gt;",
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>&lt;escape me!&gt;</p>")
// )
// }
}
@Mathspy
Copy link
Author

Mathspy commented Nov 18, 2021

  • IntoAttributes needs to be changed to be similar to IntoHtml. (No longer be a supertrait and has an ESCAPED constant to determine whether its content has been escaped or needs escaping) Done in v3
  • You can add Tag::Fragment and remove Option<> wrapper around tag: in HtmlTag. Fragment's starting() and ending() should both be "". And then text can just use Tag::Fragment. Done in v2
  • There are a couple of places that haven't implemented escaping, but there's at least one place that did which shows an example of how to do it. UPDATE: only attributes left. UPDATE 2: More complicated than it seems, see below
  • To support more than &'static str you could switch to Cow<'static, str> which imposes a bit of extra branching but allows supporting both static string slices and owned strings at the same time Kinda done in v3, see below

@Mathspy
Copy link
Author

Mathspy commented Nov 18, 2021

Oh epic I made a typo in file name and so it will be like that forever. Welp 🤷‍♀️

@Mathspy
Copy link
Author

Mathspy commented Nov 18, 2021

After a nap I have decided to actually dog food this library, so now it's going to be used to make my game dev diary

@Mathspy
Copy link
Author

Mathspy commented Nov 18, 2021

So couple of things

So with all of that in mind I shall put this little renderer to rest and possibly come back later to it, hopefully with a fresh perspective and more knowledge!

@Mathspy
Copy link
Author

Mathspy commented Nov 19, 2021

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 with LendingIterators this won't fit with our current implementation because the Cows won't be 'static coming from a String.
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 a String, allocate a new String 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 LendingIterators are stable and see what I can do about it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment