jdrouet/mrml

View on GitHub
packages/mrml-core/src/mj_head/render.rs

Summary

Maintainability
Test Coverage
use super::MjHead;
use crate::helper::sort::sort_by_key;
use crate::prelude::hash::Map;
use crate::prelude::render::*;

const STYLE_BASE: &str = r#"
<style type="text/css">
#outlook a { padding: 0; }
body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
p { display: block; margin: 13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
  <o:AllowPNG/>
  <o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
"#;

fn combine_attribute_map<'a>(
    mut res: Map<&'a str, Map<&'a str, &'a str>>,
    (name, key, value): (&'a str, &'a str, &'a str),
) -> Map<&'a str, Map<&'a str, &'a str>> {
    let entry = res.entry(name).or_default();
    entry.insert(key, value);
    res
}

impl MjHead {
    pub fn build_attributes_all(&self) -> Map<&str, &str> {
        self.children
            .iter()
            .flat_map(|item| {
                item.as_mj_attributes()
                    .into_iter()
                    .flat_map(|inner| inner.mj_attributes_all_iter())
                    .chain(
                        item.as_mj_include()
                            .filter(|item| item.0.attributes.kind.is_mjml())
                            .into_iter()
                            .flat_map(|inner| inner.mj_attributes_all_iter()),
                    )
            })
            .collect()
    }

    pub fn build_attributes_class(&self) -> Map<&str, Map<&str, &str>> {
        self.children
            .iter()
            .flat_map(|item| {
                item.as_mj_attributes()
                    .into_iter()
                    .flat_map(|inner| inner.mj_attributes_class_iter())
                    .chain(
                        item.as_mj_include()
                            .filter(|item| item.0.attributes.kind.is_mjml())
                            .into_iter()
                            .flat_map(|inner| inner.mj_attributes_class_iter()),
                    )
            })
            .fold(Map::new(), combine_attribute_map)
    }

    pub fn build_attributes_element(&self) -> Map<&str, Map<&str, &str>> {
        self.children
            .iter()
            .flat_map(|item| {
                item.as_mj_attributes()
                    .into_iter()
                    .flat_map(|inner| inner.mj_attributes_element_iter())
                    .chain(
                        item.as_mj_include()
                            .filter(|item| item.0.attributes.kind.is_mjml())
                            .into_iter()
                            .flat_map(|inner| inner.mj_attributes_element_iter()),
                    )
            })
            .fold(Map::new(), combine_attribute_map)
    }

    pub fn build_font_families(&self) -> Map<&str, &str> {
        self.children
            .iter()
            .flat_map(|item| {
                item.as_mj_font()
                    .into_iter()
                    .chain(item.as_mj_include().into_iter().flat_map(|incl| {
                        incl.0
                            .children
                            .iter()
                            .filter_map(|child| child.as_mj_font())
                    }))
            })
            .map(|font| (font.name(), font.href()))
            .collect()
    }
}

fn render_font_import(target: &mut String, href: &str) {
    target.push_str("@import url(");
    target.push_str(href);
    target.push_str(");");
}

fn render_font_link(target: &mut String, href: &str) {
    target.push_str("<link href=\"");
    target.push_str(href);
    target.push_str("\" rel=\"stylesheet\" type=\"text/css\">");
}

impl<'root> Renderer<'root, MjHead, ()> {
    fn mj_style_iter(&self) -> impl Iterator<Item = &str> {
        self.element.children.iter().flat_map(|item| {
            item.as_mj_include()
                .into_iter()
                .flat_map(|inner| {
                    inner
                        .0
                        .children
                        .iter()
                        .filter_map(|child| child.as_mj_style())
                        .map(|child| child.children.trim())
                })
                .chain(
                    item.as_mj_include()
                        .into_iter()
                        .filter(|child| child.0.attributes.kind.is_css(false))
                        .flat_map(|child| {
                            child
                                .0
                                .children
                                .iter()
                                .filter_map(|item| item.as_text())
                                .map(|text| text.inner_str().trim())
                        }),
                )
                .chain(
                    item.as_mj_style()
                        .into_iter()
                        .map(|item| item.children.trim()),
                )
        })
    }

    fn render_font_families(&self, cursor: &mut RenderCursor) {
        let used_font_families = cursor.header.used_font_families();
        if used_font_families.is_empty() {
            return;
        }

        let mut links = String::default();
        let mut imports = String::default();
        for name in cursor.header.used_font_families().iter() {
            if let Some(href) = self.context.header.font_families().get(name.as_str()) {
                render_font_link(&mut links, href);
                render_font_import(&mut imports, href);
            } else if let Some(href) = self.context.options.fonts.get(name) {
                render_font_link(&mut links, href);
                render_font_import(&mut imports, href);
            } else {
                // TODO log a warning
            }
        }

        if links.is_empty() && imports.is_empty() {
        } else {
            cursor.buffer.start_mso_negation_conditional_tag();
            cursor.buffer.push_str(&links);
            if !imports.is_empty() {
                cursor.buffer.push_str("<style type=\"text/css\">");
                cursor.buffer.push_str(&imports);
                cursor.buffer.push_str("</style>");
            }
            cursor.buffer.end_negation_conditional_tag();
        }
    }

    fn render_media_queries(&self, cursor: &mut RenderCursor) {
        if cursor.header.media_queries().is_empty() {
            return;
        }
        let mut classnames = cursor.header.media_queries().iter().collect::<Vec<_>>();
        classnames.sort_by(sort_by_key);
        let breakpoint = self.context.header.breakpoint().to_string();
        cursor.buffer.push_str("<style type=\"text/css\">");
        cursor.buffer.push_str("@media only screen and (min-width:");
        cursor.buffer.push_str(breakpoint.as_str());
        cursor.buffer.push_str(") { ");
        for (classname, size) in classnames.iter() {
            let size = size.to_string();
            cursor.buffer.push('.');
            cursor.buffer.push_str(classname);
            cursor.buffer.push_str(" { width:");
            cursor.buffer.push_str(size.as_str());
            cursor.buffer.push_str(" !important; max-width:");
            cursor.buffer.push_str(size.as_str());
            cursor.buffer.push_str("; } ");
        }
        cursor.buffer.push_str(" }");
        cursor.buffer.push_str("</style>");
        cursor
            .buffer
            .push_str("<style media=\"screen and (min-width:");
        cursor.buffer.push_str(breakpoint.as_str());
        cursor.buffer.push_str(")\">");
        for (classname, size) in classnames.iter() {
            let size = size.to_string();
            cursor.buffer.push_str(".moz-text-html .");
            cursor.buffer.push_str(classname);
            cursor.buffer.push_str(" { width:");
            cursor.buffer.push_str(size.as_str());
            cursor.buffer.push_str(" !important; max-width:");
            cursor.buffer.push_str(size.as_str());
            cursor.buffer.push_str("; } ");
        }
        cursor.buffer.push_str("</style>");
    }

    fn render_styles(&self, cursor: &mut RenderCursor) {
        if !cursor.header.styles().is_empty() {
            cursor.buffer.push_str("<style type=\"text/css\">");
            for style in cursor.header.styles().iter() {
                cursor.buffer.push_str(style);
            }
            cursor.buffer.push_str("</style>");
        }

        // TODO this should be optional
        cursor.buffer.push_str("<style type=\"text/css\">");
        for item in self.mj_style_iter() {
            cursor.buffer.push_str(item);
        }
        cursor.buffer.push_str("</style>");
    }

    fn render_raw(&self, cursor: &mut RenderCursor) -> Result<(), Error> {
        let mut index: usize = 0;
        let siblings = self.element.children.len();
        for child in self.element.children.iter() {
            if let Some(mj_raw) = child.as_mj_raw() {
                let mut renderer = mj_raw.renderer(self.context());
                renderer.set_index(index);
                renderer.set_siblings(siblings);
                renderer.render(cursor)?;
                index += 1;
            } else if let Some(mj_include) = child.as_mj_include() {
                for include_child in mj_include.0.children.iter() {
                    if let Some(mj_raw) = include_child.as_mj_raw() {
                        let mut renderer = mj_raw.renderer(self.context());
                        renderer.set_index(index);
                        renderer.set_siblings(siblings);
                        renderer.render(cursor)?;
                        index += 1;
                    }
                }
            }
        }
        Ok(())
    }
}

impl<'root> Render<'root> for Renderer<'root, MjHead, ()> {
    fn context(&self) -> &'root RenderContext<'root> {
        self.context
    }

    fn render(&self, cursor: &mut RenderCursor) -> Result<(), Error> {
        cursor.buffer.push_str("<head>");
        // we write the title even though there is no content
        cursor.buffer.push_str("<title>");
        if let Some(title) = self.element.title().map(|item| item.content()) {
            cursor.buffer.push_str(title);
        }
        cursor.buffer.push_str("</title>");
        cursor.buffer.start_mso_negation_conditional_tag();
        cursor
            .buffer
            .push_str("<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">");
        cursor.buffer.end_negation_conditional_tag();
        cursor
            .buffer
            .push_str("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">");
        cursor
            .buffer
            .push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
        cursor.buffer.push_str(STYLE_BASE);
        self.render_font_families(cursor);
        self.render_media_queries(cursor);
        self.render_styles(cursor);
        self.render_raw(cursor)?;
        cursor.buffer.push_str("</head>");
        Ok(())
    }
}

impl<'render, 'root: 'render> Renderable<'render, 'root> for MjHead {
    fn renderer(
        &'root self,
        context: &'root RenderContext<'root>,
    ) -> Box<dyn Render<'root> + 'render> {
        Box::new(Renderer::new(context, self, ()))
    }
}

#[cfg(test)]
mod tests {
    use std::iter::FromIterator;

    use crate::mj_attributes::{MjAttributes, MjAttributesChild};
    use crate::mj_attributes_all::MjAttributesAll;
    use crate::mj_attributes_class::{MjAttributesClass, MjAttributesClassAttributes};
    use crate::mj_attributes_element::MjAttributesElement;
    use crate::mj_font::MjFont;
    use crate::mj_head::{MjHead, MjHeadChild};
    use crate::mj_include::head::{MjIncludeHead, MjIncludeHeadAttributes, MjIncludeHeadChild};
    use crate::prelude::hash::Map;

    crate::should_render!(attributes_basic, "mj-attributes");
    crate::should_render!(style_basic, "mj-style");

    #[test]
    fn should_keep_order_with_mj_include_attributes_all() {
        let element = MjHead::new(
            (),
            vec![
                MjHeadChild::MjAttributes(MjAttributes::new(
                    (),
                    vec![MjAttributesChild::MjAttributesAll(MjAttributesAll::new(
                        Map::from_iter([(String::from("font-size"), String::from("42px"))]),
                        (),
                    ))],
                )),
                MjHeadChild::MjInclude(MjIncludeHead::new(
                    MjIncludeHeadAttributes {
                        path: String::from("foo"),
                        kind: crate::mj_include::head::MjIncludeHeadKind::Mjml,
                    },
                    vec![MjIncludeHeadChild::MjAttributes(MjAttributes::new(
                        (),
                        vec![MjAttributesChild::MjAttributesAll(MjAttributesAll::new(
                            Map::from_iter([
                                (String::from("font-size"), String::from("21px")),
                                (String::from("text-align"), String::from("center")),
                            ]),
                            (),
                        ))],
                    ))],
                )),
                MjHeadChild::MjAttributes(MjAttributes::new(
                    (),
                    vec![MjAttributesChild::MjAttributesAll(MjAttributesAll::new(
                        Map::from_iter([(String::from("text-align"), String::from("right"))]),
                        (),
                    ))],
                )),
            ],
        );
        assert_eq!(
            element.build_attributes_all().get("font-size"),
            Some("21px").as_ref()
        );
        assert_eq!(
            element.build_attributes_all().get("text-align"),
            Some("right").as_ref()
        );
    }

    #[test]
    fn should_keep_order_with_mj_include_attributes_class() {
        let element = MjHead::new(
            (),
            vec![
                MjHeadChild::MjAttributes(MjAttributes::new(
                    (),
                    vec![MjAttributesChild::MjAttributesClass(
                        MjAttributesClass::new(
                            MjAttributesClassAttributes {
                                name: String::from("foo"),
                                others: Map::from_iter([(
                                    String::from("font-size"),
                                    String::from("42px"),
                                )]),
                            },
                            (),
                        ),
                    )],
                )),
                MjHeadChild::MjInclude(MjIncludeHead::new(
                    MjIncludeHeadAttributes {
                        path: String::from("foo"),
                        kind: crate::mj_include::head::MjIncludeHeadKind::Mjml,
                    },
                    vec![MjIncludeHeadChild::MjAttributes(MjAttributes::new(
                        (),
                        vec![
                            MjAttributesChild::MjAttributesClass(MjAttributesClass::new(
                                MjAttributesClassAttributes {
                                    name: String::from("foo"),
                                    others: Map::from_iter([(
                                        String::from("font-size"),
                                        String::from("21px"),
                                    )]),
                                },
                                (),
                            )),
                            MjAttributesChild::MjAttributesClass(MjAttributesClass::new(
                                MjAttributesClassAttributes {
                                    name: String::from("bar"),
                                    others: Map::from_iter([(
                                        String::from("text-align"),
                                        String::from("center"),
                                    )]),
                                },
                                (),
                            )),
                        ],
                    ))],
                )),
                MjHeadChild::MjAttributes(MjAttributes::new(
                    (),
                    vec![MjAttributesChild::MjAttributesClass(
                        MjAttributesClass::new(
                            MjAttributesClassAttributes {
                                name: String::from("bar"),
                                others: Map::from_iter([(
                                    String::from("text-align"),
                                    String::from("left"),
                                )]),
                            },
                            (),
                        ),
                    )],
                )),
            ],
        );
        let attributes = element.build_attributes_class();
        assert_eq!(
            attributes.get("foo").unwrap().get("font-size"),
            Some("21px").as_ref()
        );
        assert_eq!(
            attributes.get("bar").unwrap().get("text-align"),
            Some("left").as_ref()
        );
    }

    #[test]
    fn should_keep_order_with_mj_include_attributes_element() {
        let element = MjHead::new(
            (),
            vec![
                MjHeadChild::MjAttributes(MjAttributes::new(
                    (),
                    vec![MjAttributesChild::MjAttributesElement(
                        MjAttributesElement {
                            name: String::from("mj-text"),
                            attributes: Map::from_iter([(
                                String::from("font-size"),
                                String::from("42px"),
                            )]),
                        },
                    )],
                )),
                MjHeadChild::MjInclude(MjIncludeHead::new(
                    MjIncludeHeadAttributes {
                        path: String::from("foo"),
                        kind: crate::mj_include::head::MjIncludeHeadKind::Mjml,
                    },
                    vec![MjIncludeHeadChild::MjAttributes(MjAttributes::new(
                        (),
                        vec![MjAttributesChild::MjAttributesElement(
                            MjAttributesElement {
                                name: String::from("mj-text"),
                                attributes: Map::from_iter([
                                    (String::from("font-size"), String::from("21px")),
                                    (String::from("text-align"), String::from("center")),
                                ]),
                            },
                        )],
                    ))],
                )),
                MjHeadChild::MjAttributes(MjAttributes::new(
                    (),
                    vec![MjAttributesChild::MjAttributesElement(
                        MjAttributesElement {
                            name: String::from("mj-text"),
                            attributes: Map::from_iter([(
                                String::from("text-align"),
                                String::from("left"),
                            )]),
                        },
                    )],
                )),
            ],
        );
        let attributes = element.build_attributes_element();
        assert_eq!(
            attributes.get("mj-text").unwrap().get("font-size"),
            Some("21px").as_ref()
        );
        assert_eq!(
            attributes.get("mj-text").unwrap().get("text-align"),
            Some("left").as_ref()
        );
    }

    #[test]
    fn should_keep_order_with_mj_font() {
        let element = MjHead::new(
            (),
            vec![
                MjHeadChild::MjFont(MjFont::build("foo", "http://foo/root")),
                MjHeadChild::MjInclude(MjIncludeHead::new(
                    MjIncludeHeadAttributes {
                        path: String::from("foo"),
                        kind: crate::mj_include::head::MjIncludeHeadKind::Mjml,
                    },
                    vec![
                        MjIncludeHeadChild::MjFont(MjFont::build("foo", "http://foo/include")),
                        MjIncludeHeadChild::MjFont(MjFont::build("bar", "http://bar/include")),
                    ],
                )),
                MjHeadChild::MjFont(MjFont::build("bar", "http://bar/root")),
            ],
        );
        let fonts = element.build_font_families();
        assert_eq!(fonts.get("foo"), Some("http://foo/include").as_ref());
        assert_eq!(fonts.get("bar"), Some("http://bar/root").as_ref());
    }
}