attrs =
15 | new LinkedHashMap<>(super.allowedAttributes());
16 | attrs.put("gap", AttributeValueType.of(null));
17 | return attrs;
18 | }
19 |
20 | @Override
21 | StringBuilder renderChildren(HtmlRenderer renderer) {
22 | var res = new StringBuilder();
23 |
24 | for (var childComponent : getChildren()) {
25 | var childContent = childComponent.renderMjml(renderer);
26 | if (Utils.isNullOrWhiteSpace(childContent)) {
27 | continue;
28 | }
29 |
30 | if (childComponent.isRawElement()) {
31 | res.append(childContent);
32 | } else if (childComponent instanceof BodyComponent component) {
33 | renderer.appendCurrentSpacing(res);
34 | renderer.openIfMsoIE(res, true);
35 | renderer.openTag("tr", res);
36 | res.append("| \n");
45 | renderer.appendCurrentSpacing(res);
46 | renderer.closeEndif(res, true);
47 | res.append(childContent);
48 | renderer.openIfMsoIE(res);
49 | renderer.closeTag("td", res);
50 | renderer.closeTag("tr", res);
51 | renderer.closeEndif(res);
52 | }
53 | }
54 | return res;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/data/components/accordion.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Why use an accordion?
17 |
18 |
19 | Because emails with a lot of content are most of the time a very bad experience on mobile, mj-accordion comes handy when you want to deliver a lot of information in a concise way.
20 |
21 |
22 |
23 |
24 | How it works
25 |
26 |
27 | Content is stacked into tabs and users can expand them at will. If responsive styles are not supported (mostly on desktop clients), tabs are then expanded and your content is readable at once.
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/data/components/accordion-attrs-all.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Why use an accordion?
14 |
15 |
16 |
17 |
18 | Why use an accordion?
19 |
20 |
21 |
22 |
23 | Why use an accordion?
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | Why use an accordion?
32 |
33 |
34 |
35 |
36 | Why use an accordion?
37 |
38 |
39 |
40 |
41 | Why use an accordion?
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/data/complex/welcome-email.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Welcome aboard
11 |
12 |
13 |
14 |
15 | Dear [[FirstName]] Welcome to [[CompanyName]].
16 | We're really excited you've decided to give us a try. In case you have any questions, feel free to reach out to us at [[ContactEmail]]. You can login to your account with your username [[UserName]]
17 | Login
18 | Thanks, The [[CompanyName]] Team
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/data/components/accordion-attrs.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Why use an accordion?
16 |
17 |
18 |
19 |
20 | Why use an accordion?
21 |
22 |
23 |
24 |
25 | Why use an accordion?
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | Why use an accordion?
34 |
35 |
36 |
37 |
38 | Why use an accordion?
39 |
40 |
41 |
42 |
43 | Why use an accordion?
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentHeadAttributes.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import java.util.LinkedHashMap;
4 | import org.w3c.dom.Attr;
5 | import org.w3c.dom.Element;
6 | import org.w3c.dom.Node;
7 |
8 | class MjmlComponentHeadAttributes extends BaseComponent.HeadComponent {
9 |
10 | MjmlComponentHeadAttributes(Element element, BaseComponent parent, GlobalContext context) {
11 | super(element, parent, context);
12 | process();
13 | }
14 |
15 | @Override
16 | LinkedHashMap allowedAttributes() {
17 | return Utils.EMPTY_MAP;
18 | }
19 |
20 | @Override
21 | StringBuilder renderMjml(HtmlRenderer renderer) {
22 | handler();
23 | return new StringBuilder(0);
24 | }
25 |
26 | @Override
27 | void handler() {}
28 |
29 | private void process() {
30 | var childNodes = getElement().getChildNodes();
31 | var count = childNodes.getLength();
32 | for (var i = 0; i < count; i++) {
33 | var childNode = childNodes.item(i);
34 | if (childNode.getNodeType() == Node.ELEMENT_NODE && childNode instanceof Element element) {
35 | handleElement(element);
36 | }
37 | }
38 | }
39 |
40 | private void handleElement(Element element) {
41 | if ("mj-class".equals(element.getTagName())) {
42 | var className = element.getAttribute("name");
43 | if (className != null) {
44 | var attributesLength = element.getAttributes().getLength();
45 | var attributes = element.getAttributes();
46 | for (var i = 0; i < attributesLength; i++) {
47 | var attribute = (Attr) attributes.item(i);
48 | var attributeName = attribute.getName();
49 | var attributeValue = attribute.getValue();
50 | if (!"name".equals(attributeName)) {
51 | context.setClassAttribute(attributeName, className, attributeValue);
52 | }
53 | }
54 | }
55 | } else {
56 | var tagName = element.getTagName();
57 | var attributesLength = element.getAttributes().getLength();
58 | var attributes = element.getAttributes();
59 | for (var i = 0; i < attributesLength; i++) {
60 | var attribute = (Attr) attributes.item(i);
61 | var attributeName = attribute.getName();
62 | var attributeValue = attribute.getValue();
63 | context.setTypeAttribute(attributeName, tagName, attributeValue);
64 | }
65 | }
66 | }
67 |
68 | @Override
69 | boolean isEndingTag() {
70 | return true;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/data/upstream/tableWidth.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | |
9 | Default Width
10 | |
11 |
12 | 100%
13 | |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | |
23 | Pixel Width
24 | |
25 |
26 | 500px
27 | |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | |
37 | Percentage Width
38 | |
39 |
40 | 80%
41 | |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | |
51 | Auto Width
52 | |
53 |
54 | Auto
55 | |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/data/complex/proof.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Article Title
14 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sit amet ipsum consequat.
15 | READ MORE
16 |
17 |
18 |
19 |
20 |
21 | Article Title
22 | Lorem ipsum dolor sit amet, consectetur adipiscing elit.
23 | READ MORE
24 |
25 |
26 |
27 | Article Title
28 | Lorem ipsum dolor sit amet, consectetur adipiscing elit.
29 | READ MORE
30 |
31 |
32 |
33 | Article Title
34 | Lorem ipsum dolor sit amet, consectetur adipiscing elit.
35 | READ MORE
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/HtmlComponent.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import java.util.LinkedHashMap;
4 | import org.w3c.dom.Element;
5 |
6 | class HtmlComponent {
7 |
8 | static class HtmlRawComponent extends BaseComponent.BodyComponent {
9 | HtmlRawComponent(Element element, BaseComponent parent, GlobalContext context) {
10 | super(element, parent, context);
11 | }
12 |
13 | @Override
14 | boolean isRawElement() {
15 | return true;
16 | }
17 |
18 | @Override
19 | LinkedHashMap allowedAttributes() {
20 | return Utils.EMPTY_MAP;
21 | }
22 |
23 | @Override
24 | void setAttributes() {
25 | var attributes = getElement().getAttributes();
26 | var attributesCount = attributes.getLength();
27 | for (var i = 0; i < attributesCount; i++) {
28 | var attribute = attributes.item(i);
29 | getAttributes().put(attribute.getNodeName(), attribute.getNodeValue());
30 | }
31 | }
32 |
33 | @Override
34 | StringBuilder renderMjml(HtmlRenderer renderer) {
35 | var nodeName = getElement().getNodeName();
36 | return new StringBuilder("<")
37 | .append(nodeName)
38 | .append(">\n")
39 | .append(renderChildren(renderer))
40 | .append("")
41 | .append(nodeName)
42 | .append(">");
43 | }
44 | }
45 |
46 | static class HtmlCommentComponent extends BaseComponent.BodyComponent {
47 | HtmlCommentComponent(Element element, BaseComponent parent, GlobalContext context) {
48 | super(element, parent, context);
49 | }
50 |
51 | @Override
52 | boolean isRawElement() {
53 | return true;
54 | }
55 |
56 | @Override
57 | void setupPostConstruction() {
58 | // do nothing
59 | }
60 |
61 | @Override
62 | LinkedHashMap allowedAttributes() {
63 | return Utils.EMPTY_MAP;
64 | }
65 |
66 | @Override
67 | StringBuilder renderMjml(HtmlRenderer renderer) {
68 | return new StringBuilder("\n");
69 | }
70 | }
71 |
72 | static class HtmlTextComponent extends BaseComponent.BodyComponent {
73 |
74 | HtmlTextComponent(Element element, BaseComponent parent, GlobalContext context) {
75 | super(element, parent, context);
76 | }
77 |
78 | @Override
79 | void setupPostConstruction() {
80 | // nothing
81 | }
82 |
83 | @Override
84 | boolean isRawElement() {
85 | return true;
86 | }
87 |
88 | @Override
89 | LinkedHashMap allowedAttributes() {
90 | return Utils.EMPTY_MAP;
91 | }
92 |
93 | @Override
94 | StringBuilder renderMjml(HtmlRenderer renderer) {
95 | return new StringBuilder(getElement().getTextContent());
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/test/java/ch/digitalfondue/mjml4j/FileSystemResolverTest.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import java.nio.file.Path;
4 | import org.junit.jupiter.api.Assertions;
5 | import org.junit.jupiter.api.Test;
6 |
7 | class FileSystemResolverTest {
8 |
9 | @Test
10 | void checkRelativeResolve() {
11 | var resolver = new Mjml4j.FileSystemResolver(Path.of("/base/"));
12 | Assertions.assertEquals("/base/test.mjml", resolver.resolvePath("test.mjml", null));
13 | Assertions.assertEquals(
14 | "/base/include/test.mjml", resolver.resolvePath("include/test.mjml", "/base/test.mjml"));
15 | Assertions.assertEquals(
16 | "/base/back.mjml", resolver.resolvePath("../back.mjml", "/base/include/test.mjml"));
17 | }
18 |
19 | @Test
20 | void checkRelativeResolveBaseRelative() {
21 | var base = Path.of("base/");
22 | var resolver = new Mjml4j.FileSystemResolver(base);
23 | var baseAbsolutePath = base.toAbsolutePath();
24 | Assertions.assertEquals(
25 | baseAbsolutePath + "/test.mjml", resolver.resolvePath("test.mjml", null));
26 | Assertions.assertEquals(
27 | baseAbsolutePath + "/base/include/test.mjml",
28 | resolver.resolvePath("include/test.mjml", baseAbsolutePath + "/base/test.mjml"));
29 | Assertions.assertEquals(
30 | baseAbsolutePath + "/base/back.mjml",
31 | resolver.resolvePath("../back.mjml", baseAbsolutePath + "/base/include/test.mjml"));
32 | }
33 |
34 | @Test
35 | void checkAbsoluteResolve() {
36 | var resolver = new Mjml4j.FileSystemResolver(Path.of("/base/"));
37 | Assertions.assertEquals("/base/test.mjml", resolver.resolvePath("/test.mjml", null));
38 | Assertions.assertEquals(
39 | "/base/include/include2/test.mjml",
40 | resolver.resolvePath("/include/include2/test.mjml", "/base/include/hello.mjml"));
41 | }
42 |
43 | @Test
44 | void checkAbsoluteResolveBaseRelative() {
45 | var base = Path.of("base/");
46 | var resolver = new Mjml4j.FileSystemResolver(base);
47 | var baseAbsolutePath = base.toAbsolutePath();
48 | Assertions.assertEquals(
49 | baseAbsolutePath + "/test.mjml", resolver.resolvePath("/test.mjml", null));
50 | Assertions.assertEquals(
51 | baseAbsolutePath + "/include/include2/test.mjml",
52 | resolver.resolvePath(
53 | "/include/include2/test.mjml", baseAbsolutePath + "/base/include/hello.mjml"));
54 | }
55 |
56 | @Test
57 | void checkOutsideOfBasePath() {
58 | var resolver = new Mjml4j.FileSystemResolver(Path.of("/base/"));
59 | Assertions.assertThrows(
60 | IllegalStateException.class,
61 | () -> {
62 | resolver.resolvePath("../test.mjml", null);
63 | });
64 |
65 | Assertions.assertThrows(
66 | IllegalStateException.class,
67 | () -> {
68 | resolver.resolvePath("../../../../../test.mjml", "/base/include/test.mjml");
69 | });
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/data/complex/basic.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | My Company
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Slogan here
17 | Promotion
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | My Awesome Text
26 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin rutrum enim eget magna efficitur, eu semper augue semper. Aliquam erat volutpat. Cras id dui lectus. Vestibulum sed finibus lectus, sit amet suscipit nibh. Proin nec commodo purus.
27 | Sed eget nulla elit. Nulla aliquet mollis faucibus.
28 | Learn more
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | Find amazing places
46 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin rutrum enim eget magna efficitur, eu semper augue semper. Aliquam erat volutpat. Cras id dui lectus. Vestibulum sed finibus lectus.
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/data/complex/newsletter.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | Here is what you've missed
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Sed ut perspiciatis
27 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Explore our new features
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Right on time!
49 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
50 |
51 |
52 |
53 |
54 | Stay in touch!
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/test/java/ch/digitalfondue/mjml4j/testutils/Helpers.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j.testutils;
2 |
3 | import ch.digitalfondue.mjml4j.Mjml4j;
4 | import java.io.File;
5 | import java.io.IOException;
6 | import java.nio.charset.StandardCharsets;
7 | import java.nio.file.Files;
8 | import java.nio.file.Path;
9 | import java.util.ArrayList;
10 | import java.util.HashMap;
11 | import java.util.regex.Pattern;
12 | import org.graalvm.polyglot.Context;
13 | import org.graalvm.polyglot.Source;
14 | import org.graalvm.polyglot.io.IOAccess;
15 | import org.junit.jupiter.api.Assertions;
16 |
17 | public class Helpers {
18 | private static String beautifyHtml(String html) throws IOException {
19 | System.getProperties().setProperty("polyglot.engine.WarnInterpreterOnly", "false");
20 |
21 | var options = new HashMap();
22 | // Enable CommonJS experimental support.
23 | options.put("js.commonjs-require", "true");
24 | options.put("js.commonjs-require-cwd", "node_modules/");
25 |
26 | try (Context context =
27 | Context.newBuilder("js")
28 | .allowExperimentalOptions(true)
29 | .allowIO(IOAccess.ALL)
30 | .options(options)
31 | .build()) {
32 | context.eval(
33 | Source.newBuilder("js", new File("node_modules/js-beautify/js/index.js")).build());
34 | context.getBindings("js").putMember("input", html);
35 | var res =
36 | context.eval(
37 | Source.newBuilder(
38 | "js",
39 | """
40 | require('js-beautify').html(input, {
41 | indent_size: 2,
42 | wrap_attributes_indent_size: 2,
43 | max_preserve_newline: 0,
44 | preserve_newlines: false,
45 | end_with_newline: true,
46 | });
47 | """,
48 | "test.mjs")
49 | .build());
50 | return res.asString();
51 | }
52 | }
53 |
54 | private static String simplifyBrTags(String input) {
55 | return input.replaceAll(" ", " ");
56 | }
57 |
58 | // align all values defined in ids -> mjml use random strings, for coherence reason we find all
59 | // id="...." and replace them with
60 | // a sequence. this allows to align id=".." and for=".."
61 | private static String alignIdFor(String input) {
62 | var findIds =
63 | Pattern.compile(
64 | "id=\"[^\"]*([0-9a-f]{16})[^\"]*\"", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
65 | var matcher = findIds.matcher(input);
66 | var matches = new ArrayList();
67 | while (matcher.find()) {
68 | matches.add(matcher.group(1));
69 | }
70 | var res = input;
71 | int i = 0;
72 | for (var id : matches) {
73 | i++;
74 | res = res.replace(id, "replacement_id_" + i);
75 | }
76 | return res;
77 | }
78 |
79 | public static void testTemplate(String directory, String name) {
80 | var resolver = new Mjml4j.FileSystemResolver(Path.of("data", directory));
81 | try {
82 | var template =
83 | Files.readString(Path.of("data", directory, name + ".mjml"), StandardCharsets.UTF_8);
84 | var conf = new Mjml4j.Configuration("und", Mjml4j.TextDirection.AUTO, resolver);
85 | var res = Mjml4j.render(template, conf);
86 | var comparison =
87 | Files.readString(Path.of("data", "compiled", name + ".html"), StandardCharsets.UTF_8);
88 | Assertions.assertEquals(
89 | simplifyBrTags(alignIdFor(beautifyHtml(comparison))), alignIdFor(beautifyHtml(res)));
90 | } catch (IOException e) {
91 | throw new IllegalStateException(e);
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/data/complex/reactivation-email.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | the only way to travel
15 |
16 |
17 |
18 |
19 |
20 | Hey {{FirstName}}
21 |
22 |
23 | It's been a long time since you last traveled with us.
24 |
25 | Have a look at some of the top destinations people are booking right now!
26 |
27 |
28 |
29 |
30 |
31 | New York
32 | $399
33 |
34 |
35 |
36 |
37 | London
38 | $399
39 |
40 |
41 |
42 |
43 | Berlin
44 | $399
45 |
46 |
47 |
48 |
49 |
50 | Best,
The {{Company}} Team
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/data/complex/black-friday.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | WOMEN | MEN | KIDS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | Black Friday
20 |
21 |
22 | Take an extra 50% off Use code SALEONSALE* at checkout
23 |
24 |
25 |
26 |
27 |
28 | Shop Now
29 |
30 | * Offer valid on Allura purchases on 17/29/11 at 11:59 pm. No price adjustments on previous purchases, offer limited to stock. Cannot be combined with any offer or promotion other than free.
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentText.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
5 | import static java.util.Map.entry;
6 |
7 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
8 | import java.util.LinkedHashMap;
9 | import org.w3c.dom.Element;
10 |
11 | class MjmlComponentText extends BaseComponent.BodyComponent {
12 |
13 | MjmlComponentText(Element element, BaseComponent parent, GlobalContext context) {
14 | super(element, parent, context);
15 | }
16 |
17 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
18 | mapOf(
19 | entry("align", of("left")),
20 | entry("background-color", of(null, AttributeType.COLOR)),
21 | entry("color", of("#000000", AttributeType.COLOR)),
22 | entry("container-background-color", of(null, AttributeType.COLOR)),
23 | entry("font-family", of("Ubuntu, Helvetica, Arial, sans-serif")),
24 | entry("font-size", of("13px")),
25 | entry("font-style", of(null)),
26 | entry("font-weight", of(null)),
27 | entry("height", of(null)),
28 | entry("letter-spacing", of(null)),
29 | entry("line-height", of("1")),
30 | entry("padding-bottom", of(null)),
31 | entry("padding-left", of(null)),
32 | entry("padding-right", of(null)),
33 | entry("padding-top", of(null)),
34 | entry("padding", of("10px 25px")),
35 | entry("text-decoration", of(null)),
36 | entry("text-transform", of(null)),
37 | entry("vertical-align", of(null)));
38 |
39 | @Override
40 | LinkedHashMap allowedAttributes() {
41 | return ALLOWED_DEFAULT_ATTRIBUTES;
42 | }
43 |
44 | @Override
45 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
46 | cssStyleLibraries.add(
47 | "text",
48 | mapOf(
49 | "font-family", getAttribute("font-family"),
50 | "font-size", getAttribute("font-size"),
51 | "font-style", getAttribute("font-style"),
52 | "font-weight", getAttribute("font-weight"),
53 | "letter-spacing", getAttribute("letter-spacing"),
54 | "line-height", getAttribute("line-height"),
55 | "text-align", getAttribute("align"),
56 | "text-decoration", getAttribute("text-decoration"),
57 | "text-transform", getAttribute("text-transform"),
58 | "color", getAttribute("color"),
59 | "height", getAttribute("height")));
60 | }
61 |
62 | private StringBuilder renderContent() {
63 | var res = new StringBuilder();
64 | res.append("");
65 | DOMSerializer.serializeInner(getElement(), res);
66 | res.append(" \n");
67 | return res;
68 | }
69 |
70 | @Override
71 | StringBuilder renderMjml(HtmlRenderer renderer) {
72 |
73 | var height = getAttribute("height");
74 | if (Utils.isNullOrWhiteSpace(height)) {
75 | return renderContent();
76 | }
77 | var res = new StringBuilder();
78 |
79 | res.append(
80 | Utils.conditionalTag(
81 | "\n"
82 | + "\n"
83 | + "| \n"));
88 | res.append(renderContent());
89 | res.append(
90 | Utils.conditionalTag(
91 | """
92 | |
93 |
94 |
95 | """));
96 |
97 | return res;
98 | }
99 |
100 | @Override
101 | boolean isEndingTag() {
102 | return true;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentAccordionText.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
5 |
6 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
7 | import java.util.LinkedHashMap;
8 | import java.util.Map;
9 | import org.w3c.dom.Element;
10 |
11 | class MjmlComponentAccordionText extends BaseComponent.BodyComponent {
12 | MjmlComponentAccordionText(Element element, BaseComponent parent, GlobalContext context) {
13 | super(element, parent, context);
14 | }
15 |
16 | @Override
17 | boolean isEndingTag() {
18 | return true;
19 | }
20 |
21 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
22 | mapOf(
23 | Map.entry("background-color", of(null, AttributeType.COLOR)),
24 | Map.entry("font-size", of("13px")),
25 | Map.entry("font-family", of(null)),
26 | Map.entry("font-weight", of(null)),
27 | Map.entry("letter-spacing", of(null)),
28 | Map.entry("line-height", of("1")),
29 | Map.entry("color", of(null, AttributeType.COLOR)),
30 | Map.entry("padding-bottom", of(null)),
31 | Map.entry("padding-left", of(null)),
32 | Map.entry("padding-right", of(null)),
33 | Map.entry("padding-top", of(null)),
34 | Map.entry("padding", of("16px")));
35 |
36 | @Override
37 | LinkedHashMap allowedAttributes() {
38 | return ALLOWED_DEFAULT_ATTRIBUTES;
39 | }
40 |
41 | @Override
42 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
43 | cssStyleLibraries.add(
44 | "td",
45 | mapOf(
46 | "background", getAttribute("background-color"),
47 | "font-size", getAttribute("font-size"),
48 | "font-family", resolveFontFamily(),
49 | "font-weight", getAttribute("font-weight"),
50 | "letter-spacing", getAttribute("letter-spacing"),
51 | "line-height", getAttribute("line-height"),
52 | "color", getAttribute("color"),
53 | "padding", getAttribute("padding"),
54 | "padding-bottom", getAttribute("padding-bottom"),
55 | "padding-left", getAttribute("padding-left"),
56 | "padding-right", getAttribute("padding-right"),
57 | "padding-top", getAttribute("padding-top")));
58 |
59 | cssStyleLibraries.add("table", mapOf("width", "100%", "border-bottom", getAttribute("border")));
60 | }
61 |
62 | private String resolveFontFamily() {
63 | if (hasRawAttribute("font-family")) {
64 | return getAttribute("font-family");
65 | }
66 | if (localContext.elementFontFamily() != null) {
67 | return localContext.elementFontFamily();
68 | } else if (localContext.accordionFontFamily() != null) {
69 | return localContext.accordionFontFamily();
70 | } else {
71 | return getAttribute("font-family");
72 | }
73 | }
74 |
75 | @Override
76 | StringBuilder renderMjml(HtmlRenderer renderer) {
77 | var res = new StringBuilder();
78 |
79 | renderer.openTag("div", htmlAttributes(mapOf("class", "mj-accordion-content")), res);
80 | renderer.openTag(
81 | "table",
82 | htmlAttributes(
83 | mapOf(
84 | "cellspacing", "0",
85 | "cellpadding", "0",
86 | "style", "table")),
87 | res);
88 |
89 | renderer.openTag("tbody", res);
90 | renderer.openTag("tr", res);
91 |
92 | // render content
93 | renderer.openTag(
94 | "td", htmlAttributes(mapOf("class", getAttribute("css-class"), "style", "td")), res);
95 | DOMSerializer.serializeInner(getElement(), res);
96 | renderer.closeTag("td", res);
97 | //
98 | renderer.closeTag("tr", res);
99 | renderer.closeTag("tbody", res);
100 | renderer.closeTag("table", res);
101 | renderer.closeTag("div", res);
102 | return res;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/data/complex/card.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Say hello to card
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | Writing A Good Headline For Your Advertisement
15 |
16 |
17 |
18 |
19 | // BR&AND
20 |
21 |
22 | HOME / SERVICE / THIRD
23 |
24 |
25 |
26 |
27 | Free Advertising For Your Online Business.
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | A Right Media Mix Can Make The Difference.
38 |
39 |
40 |
41 |
42 |
43 | Marketers/advertisers usually focus their efforts on the people responsible for making the purchase. In many cases, this is an effective approach but in other cases it can make for a totally useless marketing campaign.
44 | CALL TO ACTION
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | Unsubscribe from this newsletter 52 Edison Court Suite 259 / East Aidabury / Cambodi Made by svenhaustein.de
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentTable.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.*;
5 | import static java.util.Map.entry;
6 |
7 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
8 | import java.util.LinkedHashMap;
9 | import org.w3c.dom.Element;
10 |
11 | class MjmlComponentTable extends BaseComponent.BodyComponent {
12 | MjmlComponentTable(Element element, BaseComponent parent, GlobalContext context) {
13 | super(element, parent, context);
14 | }
15 |
16 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
17 | mapOf(
18 | entry("align", of("left")),
19 | entry("border", of("none")),
20 | entry("cellpadding", of("0")),
21 | entry("cellspacing", of("0")),
22 | entry("container-background-color", of(null, AttributeType.COLOR)),
23 | entry("color", of("#000000", AttributeType.COLOR)),
24 | entry("font-family", of("Ubuntu, Helvetica, Arial, sans-serif")),
25 | entry("font-size", of("13px")),
26 | entry("font-weight", of(null)),
27 | entry("line-height", of("22px")),
28 | entry("padding-bottom", of(null)),
29 | entry("padding-left", of(null)),
30 | entry("padding-right", of(null)),
31 | entry("padding-top", of(null)),
32 | entry("padding", of("10px 25px")),
33 | entry("role", of(null)),
34 | entry("table-layout", of("auto")),
35 | entry("vertical-align", of(null)),
36 | entry("width", of("100%")));
37 |
38 | @Override
39 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
40 | LinkedHashMap styles =
41 | mapOf(
42 | "color", getAttribute("color"),
43 | "font-family", getAttribute("font-family"),
44 | "font-size", getAttribute("font-size"),
45 | "line-height", getAttribute("line-height"),
46 | "table-layout", getAttribute("table-layout"),
47 | "width", getAttribute("width"),
48 | "border", getAttribute("border"));
49 | if (hasCellspacing()) {
50 | styles.put("border-collapse", "separate");
51 | }
52 | cssStyleLibraries.add("table", styles);
53 | }
54 |
55 | @Override
56 | LinkedHashMap allowedAttributes() {
57 | return ALLOWED_DEFAULT_ATTRIBUTES;
58 | }
59 |
60 | @Override
61 | StringBuilder renderMjml(HtmlRenderer renderer) {
62 | var width = getAttribute("width");
63 | var parsedWidth = width.equals("auto") ? null : CssUnitParser.parse(width);
64 | var res = new StringBuilder();
65 |
66 | var tableAttributes =
67 | mapOf(
68 | "cellpadding", getAttribute("cellpadding"),
69 | "cellspacing", getAttribute("cellspacing"),
70 | "role", getAttribute("role"));
71 |
72 | renderer.openTag(
73 | "table",
74 | htmlAttributes(
75 | mergeLeft(
76 | tableAttributes,
77 | mapOf(
78 | "width",
79 | parsedWidth == null
80 | ? "auto"
81 | : (parsedWidth.isPercent()
82 | ? width
83 | : doubleToString(parsedWidth.value())),
84 | "border", "0",
85 | "style", "table"))),
86 | res);
87 | DOMSerializer.serializeInner(getElement(), res);
88 | renderer.closeTag("table", res);
89 | return res;
90 | }
91 |
92 | boolean hasCellspacing() {
93 | var cellspacing = getAttribute("cellspacing");
94 | try {
95 | var numericValue = Double.parseDouble(cellspacing.replaceAll("[^\\d.]", ""));
96 | return numericValue > 0;
97 | } catch (NumberFormatException e) {
98 | return false;
99 | }
100 | }
101 |
102 | @Override
103 | boolean isEndingTag() {
104 | return true;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/GlobalContext.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import java.util.ArrayDeque;
4 | import java.util.ArrayList;
5 | import java.util.LinkedHashMap;
6 | import java.util.List;
7 | import org.w3c.dom.Document;
8 |
9 | class GlobalContext {
10 |
11 | final Document document;
12 | String title = "";
13 | String previewText = "";
14 | String breakpoint = "480px";
15 | String containerWidth = "600px";
16 | String backgroundColor = "";
17 | final String language;
18 | final String dir;
19 | //
20 | final Mjml4j.IncludeResolver includeResolver;
21 | final ArrayDeque currentResourcePaths = new ArrayDeque<>();
22 | final ArrayDeque rootComponents = new ArrayDeque<>();
23 | //
24 |
25 | final LinkedHashMap fonts =
26 | Utils.mapOf(
27 | "Open Sans", "https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700",
28 | "Droid Sans", "https://fonts.googleapis.com/css?family=Droid+Sans:300,400,500,700",
29 | "Lato", "https://fonts.googleapis.com/css?family=Lato:300,400,500,700",
30 | "Roboto", "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700",
31 | "Ubuntu", "https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700");
32 | final LinkedHashMap headStyle = new LinkedHashMap<>();
33 | final List componentsHeadStyle = new ArrayList<>();
34 |
35 | final LinkedHashMap mediaQueries = new LinkedHashMap<>();
36 |
37 | final LinkedHashMap> attributesByName =
38 | new LinkedHashMap<>();
39 | final LinkedHashMap> attributesByClass =
40 | new LinkedHashMap<>();
41 |
42 | final List styles = new ArrayList<>();
43 | final List inlineStyles = new ArrayList<>();
44 |
45 | GlobalContext(Document document, Mjml4j.Configuration configuration) {
46 | this.document = document;
47 | this.dir = configuration.dir().value();
48 | this.language = configuration.language();
49 |
50 | //
51 | this.includeResolver = configuration.includeResolver();
52 | //
53 | }
54 |
55 | void addFont(String name, String href) {
56 | if (fonts.containsKey(name)) {
57 | var hrefCurrent = mediaQueries.get(name);
58 | if (hrefCurrent != null && hrefCurrent.equalsIgnoreCase(href)) {
59 | return;
60 | }
61 | }
62 | fonts.put(name, href);
63 | }
64 |
65 | void addStyle(String css, boolean inline) {
66 | if (css == null || css.isBlank()) {
67 | return;
68 | }
69 |
70 | if (inline) {
71 | inlineStyles.add(css);
72 | } else {
73 | styles.add(css);
74 | }
75 | }
76 |
77 | void addHeadStyle(String componentName, String css) {
78 | if (Utils.isNullOrWhiteSpace(componentName)
79 | || Utils.isNullOrWhiteSpace(css)
80 | || headStyle.containsKey(componentName)) {
81 | return;
82 | }
83 | headStyle.put(componentName, css);
84 | }
85 |
86 | void addComponentHeadStyle(String css) {
87 | if (Utils.isNullOrWhiteSpace(css)) {
88 | return;
89 | }
90 | componentsHeadStyle.add(css);
91 | }
92 |
93 | void addMediaQuery(String className, CssUnitParser.CssParsedUnit cssParsedUnit) {
94 | var mediaQuery =
95 | "{\n width: "
96 | + cssParsedUnit
97 | + " !important;\n max-width: "
98 | + cssParsedUnit
99 | + ";\n }";
100 |
101 | if (mediaQueries.containsKey(className)) {
102 | var mediaQueryCurrent = mediaQueries.get(className);
103 |
104 | if (Utils.equalsIgnoreCase(mediaQueryCurrent, mediaQuery)) {
105 | return;
106 | }
107 | }
108 | mediaQueries.put(className, mediaQuery);
109 | }
110 |
111 | void setClassAttribute(String name, String className, String value) {
112 | if (!attributesByClass.containsKey(className)) {
113 | attributesByClass.put(className, new LinkedHashMap<>());
114 | }
115 | attributesByClass.get(className).put(name, value);
116 | }
117 |
118 | void setTypeAttribute(String name, String type, String value) {
119 | if (!attributesByName.containsKey(name)) {
120 | attributesByName.put(name, new LinkedHashMap<>());
121 | }
122 | attributesByName.get(name).put(type, value);
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentAccordionElement.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.conditionalTag;
5 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
6 | import static java.util.Map.entry;
7 |
8 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
9 | import java.util.LinkedHashMap;
10 | import org.w3c.dom.Element;
11 |
12 | class MjmlComponentAccordionElement extends BaseComponent.BodyComponent {
13 | MjmlComponentAccordionElement(Element element, BaseComponent parent, GlobalContext context) {
14 | super(element, parent, context);
15 | }
16 |
17 | @Override
18 | LocalContext getChildContext() {
19 | return localContext.withElementFontFamily(getAttribute("font-family"));
20 | }
21 |
22 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
23 | mapOf(
24 | entry("background-color", of(null, AttributeType.COLOR)),
25 | entry("border", of(null)),
26 | entry("font-family", of(null)),
27 | entry("icon-align", of(null)),
28 | entry("icon-width", of(null)),
29 | entry("icon-height", of(null)),
30 | entry("icon-wrapped-url", of(null)),
31 | entry("icon-wrapped-alt", of(null)),
32 | entry("icon-unwrapped-url", of(null)),
33 | entry("icon-unwrapped-alt", of(null)),
34 | entry("icon-position", of(null)));
35 |
36 | @Override
37 | LinkedHashMap allowedAttributes() {
38 | return ALLOWED_DEFAULT_ATTRIBUTES;
39 | }
40 |
41 | @Override
42 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
43 | cssStyleLibraries.add(
44 | "td", mapOf("padding", "0px", "background-color", getAttribute("background-color")));
45 |
46 | cssStyleLibraries.add(
47 | "label", mapOf("font-size", "13px", "font-family", getAttribute("font-family")));
48 |
49 | cssStyleLibraries.add("input", mapOf("display", "none"));
50 | }
51 |
52 | @Override
53 | String getInheritingAttribute(String attributeName) {
54 | return switch (attributeName) {
55 | case "border",
56 | "icon-align",
57 | "icon-height",
58 | "icon-position",
59 | "icon-width",
60 | "icon-unwrapped-url",
61 | "icon-unwrapped-alt",
62 | "icon-wrapped-url",
63 | "icon-wrapped-alt" ->
64 | getAttribute(attributeName);
65 | default -> null;
66 | };
67 | }
68 |
69 | private void ensureMissingElements() {
70 |
71 | var addTitle = getChildren().stream().noneMatch(MjmlComponentAccordionTitle.class::isInstance);
72 | var addText = getChildren().stream().noneMatch(MjmlComponentAccordionText.class::isInstance);
73 |
74 | if (addTitle) {
75 | var element = context.document.createElement("mj-accordion-title");
76 | var acc = new MjmlComponentAccordionTitle(element, this, context);
77 | acc.doSetupPostConstruction();
78 | getChildren().add(0, acc);
79 | }
80 |
81 | if (addText) {
82 | var element = context.document.createElement("mj-accordion-text");
83 | var acc = new MjmlComponentAccordionText(element, this, context);
84 | acc.doSetupPostConstruction();
85 | getChildren().add(acc);
86 | }
87 | }
88 |
89 | @Override
90 | StringBuilder renderMjml(HtmlRenderer renderer) {
91 | ensureMissingElements();
92 | var res = new StringBuilder();
93 |
94 | renderer.openTag("tr", htmlAttributes(mapOf("class", getAttribute("css-class"))), res);
95 | renderer.openTag("td", htmlAttributes(mapOf("style", "td")), res);
96 | renderer.openTag(
97 | "label", htmlAttributes(mapOf("class", "mj-accordion-element", "style", "label")), res);
98 |
99 | var tmp = new StringBuilder();
100 | renderer.openCloseTag(
101 | "input",
102 | htmlAttributes(
103 | mapOf("class", "mj-accordion-checkbox", "type", "checkbox", "style", "input")),
104 | tmp);
105 | res.append(conditionalTag(tmp, true));
106 | renderer.openTag("div", res);
107 | //
108 | for (var child : getChildren()) {
109 | res.append(child.renderMjml(renderer));
110 | }
111 | //
112 | renderer.closeTag("div", res);
113 | renderer.closeTag("label", res);
114 | renderer.closeTag("td", res);
115 | renderer.closeTag("tr", res);
116 | return res;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MJML4J
2 |
3 | A java based [mjml](https://mjml.io/) implementation.
4 |
5 | Require at least java 17.
6 |
7 | Javadoc: https://javadoc.io/doc/ch.digitalfondue.mjml4j/mjml4j
8 |
9 | # Why
10 |
11 | As far as I know, there is no pure java version of mjml. This library is quite compact with a single dependency - the [html5 parser (jfiveparse)](https://github.com/digitalfondue/jfiveparse).
12 |
13 | # License
14 |
15 | mjml4j is licensed under the MIT License.
16 |
17 | The code is based on the following projects:
18 |
19 | - https://github.com/mjmlio/mjml/
20 | - https://github.com/SebastianStehle/mjml-net
21 | - https://github.com/LiamRiddell/MJML.NET
22 |
23 | # Status
24 |
25 | Most of the mj-* tags are supported. It's currently missing:
26 |
27 | - ~~mj-include: will be implemented~~ implemented in 1.1.1
28 | - mj-style: the inline attribute will be ignored: will be supported when the work on css selector support is done. WIP
29 | - mj-html-attributes: will be supported when the work on css selector support is done. WIP
30 |
31 | Additionally, no pretty print/minimization of the output is provided.
32 |
33 | # Download
34 |
35 | maven:
36 |
37 | ```xml
38 |
39 | ch.digitalfondue.mjml4j
40 | mjml4j
41 | 1.1.4
42 |
43 | ```
44 |
45 | gradle:
46 |
47 | ```
48 | implementation 'ch.digitalfondue.mjml4j:mjml4j:1.1.4'
49 | ```
50 |
51 | # Use
52 |
53 | If you use it as a module, remember to add `requires ch.digitalfondue.mjml4j;` in your module-info.
54 |
55 | The api is quite simple:
56 |
57 | ```java
58 | package ch.digitalfondue.test;
59 |
60 | import ch.digitalfondue.mjml4j.Mjml4j;
61 |
62 | public class App {
63 | public static void main(String[] args) {
64 | Mjml4j.Configuration configuration = new Mjml4j.Configuration("en");
65 | String renderedTemplate = Mjml4j.render("""
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | Hello World
76 |
77 |
78 |
79 |
80 |
81 | """, configuration);
82 | System.out.println(renderedTemplate);
83 | }
84 | }
85 | ```
86 |
87 | The `render` static method accept as a parameters:
88 | 1) a string which will be then parsed and processed by the [html5 parser (jfiveparse)](https://github.com/digitalfondue/jfiveparse), or it can accept a `org.w3c.dom.Document`
89 | 2) a configuration object with language, optionally a direction and an [IncludeResolver](https://javadoc.io/doc/ch.digitalfondue.mjml4j/mjml4j/latest/ch.digitalfondue.mjml4j/ch/digitalfondue/mjml4j/Mjml4j.IncludeResolver.html)
90 |
91 |
92 | ## mj-include support
93 |
94 | By default, mjml4j don't have an [IncludeResolver](https://javadoc.io/doc/ch.digitalfondue.mjml4j/mjml4j/latest/ch.digitalfondue.mjml4j/ch/digitalfondue/mjml4j/Mjml4j.IncludeResolver.html) configured, thus `mj-include` will not work out of the box, you must implement or specify yourself.
95 | mjml4j offer 2 implementations:
96 | - [FileSystemResolver](https://javadoc.io/doc/ch.digitalfondue.mjml4j/mjml4j/latest/ch.digitalfondue.mjml4j/ch/digitalfondue/mjml4j/Mjml4j.FileSystemResolver.html) if your resources are present on the filesystem
97 | - [SimpleResourceResolver](https://javadoc.io/doc/ch.digitalfondue.mjml4j/mjml4j/latest/ch.digitalfondue.mjml4j/ch/digitalfondue/mjml4j/Mjml4j.SimpleResourceResolver.html) a resolver that need a [ResourceLoader](https://javadoc.io/doc/ch.digitalfondue.mjml4j/mjml4j/latest/ch.digitalfondue.mjml4j/ch/digitalfondue/mjml4j/Mjml4j.ResourceLoader.html) to be implemented
98 |
99 | # Development notes:
100 | - the project has a java 17 baseline
101 | - the code is formatted using the maven spotless plugin. Use `mvn spotless:apply` to format it
102 |
103 | # TODO:
104 | - validation api:
105 | - add "parent element" check
106 | - attribute unit type check
107 | - improve the renderer
108 | - cleanup/rewrite the box model, kinda hacky
109 | - more robust handling of invalid input (check mjml behaviour)
110 | - check differences/import tests
111 |
112 |
113 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentDivider.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.doubleToString;
5 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
6 | import static java.util.Map.entry;
7 |
8 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
9 | import java.util.LinkedHashMap;
10 | import java.util.Locale;
11 | import org.w3c.dom.Element;
12 |
13 | class MjmlComponentDivider extends BaseComponent.BodyComponent {
14 | MjmlComponentDivider(Element element, BaseComponent parent, GlobalContext context) {
15 | super(element, parent, context);
16 | }
17 |
18 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
19 | mapOf(
20 | entry("border-color", of("#000000", AttributeType.COLOR)),
21 | entry("border-style", of("solid")),
22 | entry("border-width", of("4px")),
23 | entry("container-background-color", of(null, AttributeType.COLOR)),
24 | entry("padding", of("10px 25px")),
25 | entry("padding-bottom", of(null)),
26 | entry("padding-left", of(null)),
27 | entry("padding-right", of(null)),
28 | entry("padding-top", of(null)),
29 | entry("width", of("100%")),
30 | entry("align", of("center")));
31 |
32 | @Override
33 | LinkedHashMap allowedAttributes() {
34 | return ALLOWED_DEFAULT_ATTRIBUTES;
35 | }
36 |
37 | private String getOutlookWidth() {
38 | var containerWidth = CssUnitParser.parse(doubleToString(getContainerOuterWidth()));
39 | var paddingSize =
40 | getShorthandAttributeValue("padding", "left")
41 | + getShorthandAttributeValue("padding", "right");
42 |
43 | var parsedWidth = CssUnitParser.parse(getAttribute("width"));
44 |
45 | switch (parsedWidth.unit().toLowerCase(Locale.ROOT)) {
46 | case "%":
47 | {
48 | var effectiveWidth = containerWidth.value() - paddingSize;
49 | var percentMultiplier = parsedWidth.value() / 100;
50 | return doubleToString(effectiveWidth * percentMultiplier) + "px";
51 | }
52 | case "px":
53 | return parsedWidth.toString();
54 | default:
55 | return doubleToString(containerWidth.value() - paddingSize) + "px";
56 | }
57 | }
58 |
59 | @Override
60 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
61 |
62 | var computeAlign = "0px auto";
63 | var alignAttribute = getAttribute("align");
64 | if ("left".equals(alignAttribute)) {
65 | computeAlign = "0px";
66 | } else if ("right".equals(alignAttribute)) {
67 | computeAlign = "0px 0px 0px auto";
68 | }
69 | var pStyle =
70 | mapOf(
71 | "border-top",
72 | getAttribute("border-style")
73 | + " "
74 | + getAttribute("border-width")
75 | + " "
76 | + getAttribute("border-color"),
77 | "font-size",
78 | "1px",
79 | "margin",
80 | computeAlign,
81 | "width",
82 | getAttribute("width"));
83 |
84 | cssStyleLibraries.add("p", pStyle);
85 |
86 | var outlookStyle = new LinkedHashMap<>(pStyle);
87 | outlookStyle.put("width", getOutlookWidth());
88 |
89 | cssStyleLibraries.add("outlook", outlookStyle);
90 | }
91 |
92 | private StringBuilder renderAfter(HtmlRenderer renderer) {
93 | var res = new StringBuilder();
94 | renderer.appendCurrentSpacing(res);
95 | res.append("\n");
115 | return res;
116 | }
117 |
118 | @Override
119 | StringBuilder renderMjml(HtmlRenderer renderer) {
120 | var res = new StringBuilder();
121 | renderer.openTag("p", htmlAttributes(mapOf("style", "p")), res);
122 | renderer.closeTag("p", res);
123 | res.append(renderAfter(renderer));
124 | return res;
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentNavbarLink.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.conditionalTag;
5 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
6 | import static java.util.Map.entry;
7 |
8 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
9 | import java.util.LinkedHashMap;
10 | import org.w3c.dom.Element;
11 |
12 | class MjmlComponentNavbarLink extends BaseComponent.BodyComponent {
13 |
14 | MjmlComponentNavbarLink(Element element, BaseComponent parent, GlobalContext context) {
15 | super(element, parent, context);
16 | }
17 |
18 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
19 | mapOf(
20 | entry("color", of("#000000", AttributeType.COLOR)),
21 | entry("font-family", of("Ubuntu, Helvetica, Arial, sans-serif")),
22 | entry("font-size", of("13px")),
23 | entry("font-style", of(null)),
24 | entry("font-weight", of("normal")),
25 | entry("href", of(null)),
26 | entry("name", of(null)),
27 | entry("target", of("_blank")),
28 | entry("rel", of(null)),
29 | entry("letter-spacing", of(null)),
30 | entry("line-height", of("22px")),
31 | entry("padding-bottom", of(null)),
32 | entry("padding-left", of(null)),
33 | entry("padding-right", of(null)),
34 | entry("padding-top", of(null)),
35 | entry("padding", of("15px 10px")),
36 | entry("text-decoration", of("none")),
37 | entry("text-transform", of("uppercase")));
38 |
39 | @Override
40 | LinkedHashMap allowedAttributes() {
41 | return ALLOWED_DEFAULT_ATTRIBUTES;
42 | }
43 |
44 | @Override
45 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
46 | cssStyleLibraries.add(
47 | "a",
48 | mapOf(
49 | "display", "inline-block",
50 | "color", getAttribute("color"),
51 | "font-family", getAttribute("font-family"),
52 | "font-size", getAttribute("font-size"),
53 | "font-style", getAttribute("font-style"),
54 | "font-weight", getAttribute("font-weight"),
55 | "letter-spacing", getAttribute("letter-spacing"),
56 | "line-height", getAttribute("line-height"),
57 | "text-decoration", getAttribute("text-decoration"),
58 | "text-transform", getAttribute("text-transform"),
59 | "padding", getAttribute("padding"),
60 | "padding-top", getAttribute("padding-top"),
61 | "padding-left", getAttribute("padding-left"),
62 | "padding-right", getAttribute("padding-right"),
63 | "padding-bottom", getAttribute("padding-bottom")));
64 |
65 | cssStyleLibraries.add(
66 | "td",
67 | mapOf(
68 | "padding", getAttribute("padding"),
69 | "padding-top", getAttribute("padding-top"),
70 | "padding-left", getAttribute("padding-left"),
71 | "padding-right", getAttribute("padding-right"),
72 | "padding-bottom", getAttribute("padding-bottom")));
73 | }
74 |
75 | private StringBuilder renderContent() {
76 | var res = new StringBuilder();
77 | var href = getAttribute("href");
78 | var navbarBaseUrl = getAttribute("navbarBaseUrl");
79 | var link = !Utils.isNullOrEmpty(navbarBaseUrl) ? navbarBaseUrl + href : href;
80 | var cssClass = hasAttribute("css-class") ? getAttribute("css-class") : "";
81 |
82 | res.append("\n");
99 | DOMSerializer.serializeInner(getElement(), res);
100 | res.append("\n\n");
101 | return res;
102 | }
103 |
104 | @Override
105 | StringBuilder renderMjml(HtmlRenderer renderer) {
106 | var res = new StringBuilder();
107 | res.append(
108 | conditionalTag(
109 | "\n"));
117 | res.append(renderContent());
118 | res.append(conditionalTag(" | \n"));
119 | return res;
120 | }
121 |
122 | @Override
123 | boolean isEndingTag() {
124 | return true;
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentAccordion.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
5 |
6 | import java.util.LinkedHashMap;
7 | import java.util.Map;
8 | import org.w3c.dom.Element;
9 |
10 | class MjmlComponentAccordion extends BaseComponent.BodyComponent {
11 | MjmlComponentAccordion(Element element, BaseComponent parent, GlobalContext context) {
12 | super(element, parent, context);
13 | }
14 |
15 | @Override
16 | LocalContext getChildContext() {
17 | return localContext.withAccordionFontFamily(getAttribute("font-family"));
18 | }
19 |
20 | @Override
21 | String headStyle() {
22 | return """
23 | noinput.mj-accordion-checkbox { display:block!important; }
24 |
25 | @media yahoo, only screen and (min-width:0) {
26 | .mj-accordion-element { display:block; }
27 | input.mj-accordion-checkbox, .mj-accordion-less { display:none!important; }
28 | input.mj-accordion-checkbox + * .mj-accordion-title { cursor:pointer; touch-action:manipulation; -webkit-user-select:none; -moz-user-select:none; user-select:none; }
29 | input.mj-accordion-checkbox + * .mj-accordion-content { overflow:hidden; display:none; }
30 | input.mj-accordion-checkbox + * .mj-accordion-more { display:block!important; }
31 | input.mj-accordion-checkbox:checked + * .mj-accordion-content { display:block; }
32 | input.mj-accordion-checkbox:checked + * .mj-accordion-more { display:none!important; }
33 | input.mj-accordion-checkbox:checked + * .mj-accordion-less { display:block!important; }
34 | }
35 |
36 | .moz-text-html input.mj-accordion-checkbox + * .mj-accordion-title { cursor: auto; touch-action: auto; -webkit-user-select: auto; -moz-user-select: auto; user-select: auto; }
37 | .moz-text-html input.mj-accordion-checkbox + * .mj-accordion-content { overflow: hidden; display: block; }
38 | .moz-text-html input.mj-accordion-checkbox + * .mj-accordion-ico { display: none; }
39 |
40 | @goodbye { @gmail }
41 | """;
42 | }
43 |
44 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
45 | mapOf(
46 | Map.entry("container-background-color", of(null, AttributeValueType.AttributeType.COLOR)),
47 | Map.entry("border", of("2px solid black")),
48 | Map.entry("font-family", of("Ubuntu, Helvetica, Arial, sans-serif")),
49 | Map.entry("icon-align", of("middle")),
50 | Map.entry("icon-width", of("32px")),
51 | Map.entry("icon-height", of("32px")),
52 | Map.entry("icon-wrapped-url", of("https://i.imgur.com/bIXv1bk.png")),
53 | Map.entry("icon-wrapped-alt", of("+")),
54 | Map.entry("icon-unwrapped-url", of("https://i.imgur.com/w4uTygT.png")),
55 | Map.entry("icon-unwrapped-alt", of("-")),
56 | Map.entry("icon-position", of("right")),
57 | Map.entry("padding-bottom", of(null)),
58 | Map.entry("padding-left", of(null)),
59 | Map.entry("padding-right", of(null)),
60 | Map.entry("padding-top", of(null)),
61 | Map.entry("padding", of("10px 25px")));
62 |
63 | @Override
64 | LinkedHashMap allowedAttributes() {
65 | return ALLOWED_DEFAULT_ATTRIBUTES;
66 | }
67 |
68 | @Override
69 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
70 | cssStyleLibraries.add(
71 | "table",
72 | mapOf(
73 | "width", "100%",
74 | "border-collapse", "collapse",
75 | "border", getAttribute("border"),
76 | "border-bottom", "none",
77 | "font-family", getAttribute("font-family")));
78 | }
79 |
80 | @Override
81 | StringBuilder renderMjml(HtmlRenderer renderer) {
82 | var res = new StringBuilder();
83 | renderer.openTag(
84 | "table",
85 | htmlAttributes(
86 | mapOf(
87 | "cellspacing", "0",
88 | "cellpadding", "0",
89 | "class", "mj-accordion",
90 | "style", "table")),
91 | res);
92 | renderer.openTag("tbody", res);
93 | for (var child : getChildren()) {
94 | res.append(child.renderMjml(renderer));
95 | }
96 | renderer.closeTag("tbody", res);
97 | renderer.closeTag("table", res);
98 |
99 | return res;
100 | }
101 |
102 | @Override
103 | String getInheritingAttribute(String name) {
104 | return switch (name) {
105 | case "border",
106 | "icon-align",
107 | "icon-height",
108 | "icon-position",
109 | "icon-width",
110 | "icon-unwrapped-url",
111 | "icon-unwrapped-alt",
112 | "icon-wrapped-url",
113 | "icon-wrapped-alt" ->
114 | getAttribute(name);
115 | default -> null;
116 | };
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/data/complex/receipt-email.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | HELLO
16 | [[FirstName]]
17 |
18 |
19 |
20 |
21 |
22 |
23 | Thank you very much for your purchase.
24 |
25 | Please find the receipt below.
26 |
27 |
28 |
29 |
30 | Order Number
31 | [[OrderNumber]]
32 |
33 |
34 | Order Date
35 | [[OrderDate]]
36 |
37 |
38 | Total Price
39 | [[TotalPrice]]
40 |
41 |
42 |
43 |
44 | Download Receipt
46 |
47 |
48 | Track My Order
50 |
51 |
52 |
53 |
54 |
55 | Best,
56 |
57 | The [[CompanyName]] Team
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentSocial.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
5 | import static java.util.Map.entry;
6 |
7 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
8 | import java.util.LinkedHashMap;
9 | import org.w3c.dom.Element;
10 |
11 | class MjmlComponentSocial extends BaseComponent.BodyComponent {
12 |
13 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
14 | mapOf(
15 | entry("align", of("center")),
16 | entry("border-radius", of("3px")),
17 | entry("container-background-color", of(null, AttributeType.COLOR)),
18 | entry("color", of("#333333", AttributeType.COLOR)),
19 | entry("font-family", of("Ubuntu, Helvetica, Arial, sans-serif")),
20 | entry("font-size", of("13px")),
21 | entry("font-style", of(null)),
22 | entry("font-weight", of(null)),
23 | entry("icon-size", of("20px")),
24 | entry("icon-height", of(null)),
25 | entry("icon-padding", of(null)),
26 | entry("inner-padding", of(null)),
27 | entry("line-height", of("22px")),
28 | entry("mode", of("horizontal")),
29 | entry("padding-bottom", of(null)),
30 | entry("padding-left", of(null)),
31 | entry("padding-right", of(null)),
32 | entry("padding-top", of(null)),
33 | entry("padding", of("10px 25px")),
34 | entry("table-layout", of(null)),
35 | entry("text-padding", of(null)),
36 | entry("text-decoration", of("none")),
37 | entry("vertical-align", of(null)));
38 |
39 | MjmlComponentSocial(Element element, BaseComponent parent, GlobalContext context) {
40 | super(element, parent, context);
41 | }
42 |
43 | @Override
44 | LinkedHashMap allowedAttributes() {
45 | return ALLOWED_DEFAULT_ATTRIBUTES;
46 | }
47 |
48 | @Override
49 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
50 | cssStyleLibraries.add("tableVertical", mapOf("margin", "0px"));
51 | }
52 |
53 | @Override
54 | StringBuilder renderMjml(HtmlRenderer renderer) {
55 | return "horizontal".equals(getAttribute("mode"))
56 | ? renderHorizontal(renderer)
57 | : renderVertical(renderer);
58 | }
59 |
60 | @Override
61 | String getInheritingAttribute(String attributeName) {
62 | return switch (attributeName) {
63 | case "padding" -> getAttribute("inner-padding");
64 | case "border-radius",
65 | "color",
66 | "font-family",
67 | "font-size",
68 | "font-style",
69 | "font-weight",
70 | "icon-height",
71 | "icon-padding",
72 | "icon-size",
73 | "line-height",
74 | "text-padding",
75 | "text-decoration" ->
76 | getAttribute(attributeName);
77 | default -> null;
78 | };
79 | }
80 |
81 | private StringBuilder renderVertical(HtmlRenderer renderer) {
82 | var res = new StringBuilder();
83 | renderer.openTag(
84 | "table",
85 | htmlAttributes(
86 | mapOf(
87 | "border", "0",
88 | "cellpadding", "0",
89 | "cellspacing", "0",
90 | "role", "presentation",
91 | "style", "tableVertical")),
92 | res);
93 | renderer.openTag("tbody", res);
94 | res.append(renderChildren(renderer));
95 | renderer.closeTag("tbody", res);
96 | renderer.closeTag("table", res);
97 |
98 | return res;
99 | }
100 |
101 | private StringBuilder renderHorizontal(HtmlRenderer renderer) {
102 | var res = new StringBuilder();
103 | renderer.appendCurrentSpacing(res);
104 | renderer.openIfMsoIE(res, true);
105 | res.append("\n");
115 | renderer.openTag("tr", res);
116 | renderer.closeEndif(res, true);
117 | for (var childComponent : getChildren()) {
118 | var childContent = childComponent.renderMjml(renderer);
119 |
120 | if (Utils.isNullOrWhiteSpace(childContent)) continue;
121 |
122 | if (childComponent.isRawElement()) {
123 | res.append(childContent);
124 | } else if (childComponent instanceof MjmlComponentSocialElement socialElementComponent) {
125 | renderer.appendCurrentSpacing(res);
126 | renderer.openIfMsoIE(res, true);
127 | renderer.openTag("td", res);
128 | renderer.closeEndif(res, true);
129 | renderer.openTag(
130 | "table",
131 | socialElementComponent.htmlAttributes(
132 | mapOf(
133 | "align", getAttribute("align"),
134 | "border", "0",
135 | "cellpadding", "0",
136 | "cellspacing", "0",
137 | "role", "presentation",
138 | "style", "float:none;display:inline-table;")),
139 | res);
140 | renderer.openTag("tbody", res);
141 | res.append(childContent);
142 | renderer.closeTag("tbody", res);
143 | renderer.closeTag("table", res);
144 | renderer.appendCurrentSpacing(res);
145 | renderer.openIfMsoIE(res, true);
146 | renderer.closeTag("td", res);
147 | renderer.closeEndif(res, true);
148 | }
149 | }
150 | renderer.openIfMsoIE(res, true);
151 | renderer.closeTag("tr", res);
152 | renderer.closeTag("table", res);
153 | renderer.closeEndif(res, true);
154 | return res;
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentAccordionTitle.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.conditionalTag;
5 | import static ch.digitalfondue.mjml4j.Utils.mapOf;
6 | import static java.util.Map.entry;
7 |
8 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
9 | import java.util.LinkedHashMap;
10 | import org.w3c.dom.Element;
11 |
12 | class MjmlComponentAccordionTitle extends BaseComponent.BodyComponent {
13 | MjmlComponentAccordionTitle(Element element, BaseComponent parent, GlobalContext context) {
14 | super(element, parent, context);
15 | }
16 |
17 | @Override
18 | boolean isEndingTag() {
19 | return true;
20 | }
21 |
22 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
23 | mapOf(
24 | entry("background-color", of(null, AttributeType.COLOR)),
25 | entry("color", of(null, AttributeType.COLOR)),
26 | entry("font-size", of("13px")),
27 | entry("font-family", of(null)),
28 | entry("font-weight", of(null)),
29 | entry("padding-bottom", of(null)),
30 | entry("padding-left", of(null)),
31 | entry("padding-right", of(null)),
32 | entry("padding-top", of(null)),
33 | entry("padding", of("16px")));
34 |
35 | @Override
36 | LinkedHashMap allowedAttributes() {
37 | return ALLOWED_DEFAULT_ATTRIBUTES;
38 | }
39 |
40 | @Override
41 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
42 | cssStyleLibraries.add(
43 | "td",
44 | mapOf(
45 | "width", "100%",
46 | "background-color", getAttribute("background-color"),
47 | "color", getAttribute("color"),
48 | "font-size", getAttribute("font-size"),
49 | "font-family", resolveFontFamily(),
50 | "font-weight", getAttribute("font-weight"),
51 | "padding", getAttribute("padding"),
52 | "padding-bottom", getAttribute("padding-bottom"),
53 | "padding-left", getAttribute("padding-left"),
54 | "padding-right", getAttribute("padding-right"),
55 | "padding-top", getAttribute("padding-top")));
56 |
57 | cssStyleLibraries.add("table", mapOf("width", "100%", "border-bottom", getAttribute("border")));
58 |
59 | cssStyleLibraries.add(
60 | "td2",
61 | mapOf(
62 | "padding", "16px",
63 | "background", getAttribute("background-color"),
64 | "vertical-align", getAttribute("icon-align")));
65 |
66 | cssStyleLibraries.add(
67 | "img",
68 | mapOf(
69 | "display", "none",
70 | "width", getAttribute("icon-width"),
71 | "height", getAttribute("icon-height")));
72 | }
73 |
74 | private String resolveFontFamily() {
75 | if (hasRawAttribute("font-family")) {
76 | return getAttribute("font-family");
77 | }
78 | if (localContext.elementFontFamily() != null) {
79 | return localContext.elementFontFamily();
80 | } else if (localContext.accordionFontFamily() != null) {
81 | return localContext.accordionFontFamily();
82 | } else {
83 | return getAttribute("font-family");
84 | }
85 | }
86 |
87 | @Override
88 | StringBuilder renderMjml(HtmlRenderer renderer) {
89 | var res = new StringBuilder();
90 |
91 | renderer.openTag("div", htmlAttributes(mapOf("class", "mj-accordion-title")), res);
92 |
93 | renderer.openTag(
94 | "table",
95 | htmlAttributes(
96 | mapOf(
97 | "cellspacing", "0",
98 | "cellpadding", "0",
99 | "style", "table")),
100 | res);
101 | renderer.openTag("tbody", res);
102 | renderer.openTag("tr", res);
103 |
104 | if ("right".equals(getAttribute("icon-position"))) {
105 | res.append(renderTitle(renderer));
106 | res.append(renderIcons(renderer));
107 | } else {
108 | res.append(renderIcons(renderer));
109 | res.append(renderTitle(renderer));
110 | }
111 |
112 | renderer.closeTag("tr", res);
113 | renderer.closeTag("tbody", res);
114 | renderer.closeTag("table", res);
115 | renderer.closeTag("div", res);
116 | return res;
117 | }
118 |
119 | private StringBuilder renderIcons(HtmlRenderer renderer) {
120 | var res = new StringBuilder();
121 | renderer.openTag(
122 | "td",
123 | htmlAttributes(
124 | mapOf(
125 | "class", "mj-accordion-ico",
126 | "style", "td2")),
127 | res);
128 |
129 | renderer.openCloseTag(
130 | "img",
131 | htmlAttributes(
132 | mapOf(
133 | "src",
134 | getAttribute("icon-wrapped-url"),
135 | "alt",
136 | getAttribute("icon-wrapped-alt"),
137 | "class",
138 | "mj-accordion-more",
139 | "style",
140 | "img")),
141 | res);
142 |
143 | renderer.openCloseTag(
144 | "img",
145 | htmlAttributes(
146 | mapOf(
147 | "src",
148 | getAttribute("icon-unwrapped-url"),
149 | "alt",
150 | getAttribute("icon-unwrapped-alt"),
151 | "class",
152 | "mj-accordion-less",
153 | "style",
154 | "img")),
155 | res);
156 |
157 | renderer.closeTag("td", res);
158 |
159 | return conditionalTag(res, true);
160 | }
161 |
162 | private StringBuilder renderTitle(HtmlRenderer renderer) {
163 | var res = new StringBuilder();
164 |
165 | renderer.openTag(
166 | "td", htmlAttributes(mapOf("class", getAttribute("css-class"), "style", "td")), res);
167 | DOMSerializer.serializeInner(getElement(), res);
168 | renderer.closeTag("td", res);
169 | return res;
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/DOMSerializer.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import org.w3c.dom.*;
4 |
5 | // directly imported and modified from JFiveParse html serializer
6 | class DOMSerializer {
7 |
8 | private static final String NAMESPACE_HTML = "http://www.w3.org/1999/xhtml";
9 | private static final String NAMESPACE_XMLNS = "http://www.w3.org/2000/xmlns/";
10 | private static final String NAMESPACE_XML = "http://www.w3.org/XML/1998/namespace";
11 | private static final String NAMESPACE_XLINK = "http://www.w3.org/1999/xlink";
12 |
13 | private static String serializeAttributeName(Attr attribute) {
14 | String lowercaseName = attribute.getName();
15 | String name = attribute.getName();
16 | String namespace = attribute.getNamespaceURI();
17 |
18 | if (NAMESPACE_XML.equals(namespace)) {
19 | return "xml:" + name;
20 | } else if (NAMESPACE_XMLNS.equals(namespace)) {
21 | return "xmlns".equals(lowercaseName) ? "xmlns" : ("xmlns:" + name);
22 | } else if (NAMESPACE_XLINK.equals(namespace)) {
23 | return "xlink:" + name;
24 | } else if (namespace != null) {
25 | return namespace + ":" + name; // TODO check
26 | } else {
27 | return name;
28 | }
29 | }
30 |
31 | static void serializeInner(Node node, StringBuilder sb) {
32 | if (node == null || !node.hasChildNodes()) {
33 | return;
34 | }
35 | var childNodes = node.getChildNodes();
36 | var count = childNodes.getLength();
37 | for (int i = 0; i < count; i++) {
38 | visit(childNodes.item(i), sb, count == 1); // we trim only if we have a single text node
39 | }
40 | }
41 |
42 | private static void visit(Node node, StringBuilder sb, boolean trim) {
43 | // open tag
44 | start(node, sb, trim);
45 | var childNodes = node.getChildNodes();
46 | var count = childNodes.getLength();
47 | for (int i = 0; i < count; i++) {
48 | visit(childNodes.item(i), sb, false);
49 | }
50 | end(node, sb);
51 | }
52 |
53 | private static void end(Node node, StringBuilder appendable) {
54 | if (node.getNodeType() == Node.ELEMENT_NODE) {
55 | Element e = (Element) node;
56 | if (!skipEndTag(e)) {
57 | appendable.append("").append(e.getNodeName()).append(">");
58 | }
59 | }
60 | }
61 |
62 | private static boolean skipEndTag(Element e) {
63 | return NAMESPACE_HTML.equals(e.getNamespaceURI()) && isNoEndTag(e.getNodeName());
64 | }
65 |
66 | private static boolean isNoEndTag(String nodeName) {
67 | return switch (nodeName) {
68 | case "area",
69 | "base",
70 | "basefont",
71 | "bgsound",
72 | "br",
73 | "col",
74 | "embed",
75 | "frame",
76 | "hr",
77 | "img",
78 | "input",
79 | "keygen",
80 | "link",
81 | "meta",
82 | "param",
83 | "source",
84 | "track",
85 | "wbr" ->
86 | true;
87 | default -> false;
88 | };
89 | }
90 |
91 | private static String escapeAttributeValue(Attr attribute) {
92 | String s = attribute.getValue();
93 | if (s != null) {
94 | s = s.replace(Character.valueOf(NO_BREAK_SPACE).toString(), " ");
95 | }
96 | return s;
97 | }
98 |
99 | private static String escapeTextData(String s) {
100 | if (s != null) {
101 | s = s.replace(Character.valueOf(NO_BREAK_SPACE).toString(), " ");
102 | }
103 | return s;
104 | }
105 |
106 | private static void start(Node node, StringBuilder appendable, boolean trim) {
107 | if (node.getNodeType() == Node.ELEMENT_NODE) {
108 | Element e = (Element) node;
109 | // TODO: for tag outside of html,mathml,svg namespace : use qualified name!
110 | appendable.append('<').append(e.getNodeName());
111 | var attributes = e.getAttributes();
112 | var attributesCount = attributes.getLength();
113 | for (int i = 0; i < attributesCount; i++) {
114 | var attr = (Attr) attributes.item(i);
115 | appendable.append(' ').append(serializeAttributeName(attr)); //
116 | appendable.append('=').append("\"").append(escapeAttributeValue(attr)).append("\"");
117 | }
118 |
119 | appendable.append('>');
120 |
121 | if ((isHtmlNS(e, "pre") || isHtmlNS(e, "textarea") || isHtmlNS(e, "listing"))
122 | && //
123 | e.hasChildNodes()
124 | && //
125 | e.getFirstChild().getNodeType() == Node.TEXT_NODE) {
126 | String text = ((Text) e.getFirstChild()).getData();
127 | if (!text.isEmpty() && text.charAt(0) == LF) {
128 | appendable.append(LF);
129 | }
130 | }
131 |
132 | } else if (node.getNodeType() == Node.TEXT_NODE) {
133 | Node parent = node.getParentNode();
134 | boolean literalAppend = false;
135 | if (parent != null && parent.getNodeType() == Node.ELEMENT_NODE) {
136 | Element p = (Element) parent;
137 | literalAppend =
138 | NAMESPACE_HTML.equals(p.getNamespaceURI())
139 | && (isTextNodeParent(p.getNodeName()) || ("noscript".equals(p.getNodeName())));
140 | }
141 | Text t = (Text) node;
142 | var text = literalAppend ? t.getData() : escapeTextData(t.getData());
143 | appendable.append(trim ? text.trim() : text);
144 | } else if (node.getNodeType() == Node.COMMENT_NODE) {
145 | appendable.append("");
146 | } else if (node.getNodeType() == Node.DOCUMENT_TYPE_NODE) {
147 | // TODO: should append the rest of the attributes if present
148 | appendable.append("');
149 | }
150 | }
151 |
152 | private static final char LF = 0x000A;
153 | private static final char NO_BREAK_SPACE = 0x00A0;
154 |
155 | private static boolean isHtmlNS(Element element, String name) {
156 | return element.getNodeName().equals(name) && element.getNamespaceURI().equals(NAMESPACE_HTML);
157 | }
158 |
159 | private static boolean isTextNodeParent(String nodeName) {
160 | return switch (nodeName) {
161 | case "style", "script", "xmp", "iframe", "noembed", "noframes", "plaintext" -> true;
162 | default -> false;
163 | };
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/test/java/ch/digitalfondue/mjml4j/UtilsTest.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import org.junit.jupiter.api.Assertions;
4 | import org.junit.jupiter.api.Test;
5 |
6 | class UtilsTest {
7 |
8 | @Test
9 | void testParseUnit() {
10 | var em = CssUnitParser.parse("32em");
11 | Assertions.assertEquals("em", em.unit());
12 | Assertions.assertEquals(32., em.value(), 0.001);
13 |
14 | var defaultPx = CssUnitParser.parse("10");
15 | Assertions.assertEquals("px", defaultPx.unit());
16 | Assertions.assertTrue(defaultPx.isPx());
17 | Assertions.assertEquals(10., defaultPx.value(), 0.001);
18 | }
19 |
20 | @Test
21 | void testMergeOutlookConditionals() {
22 | Assertions.assertEquals("", Utils.mergeOutlookConditionals("\n \n \n \n \n \n \n \n \n \n | \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n | \n \n \n Hello World\n \n \n | \n \n \n \n \n \n \n \n \n\n \n \n \n | \n
\n \n
\n \n
\n \n \n \n \n \n \n",
39 | Utils.minifyOutlookConditionals(
40 | "\n \n \n \n \n \n \n
\n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n | \n \n \n Hello World\n \n \n | \n \n \n \n \n \n \n \n \n\n \n \n \n | \n
\n \n
\n \n
\n \n \n \n \n \n
\n"));
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/data/complex/sphero-mini.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sphero Mini - Mix and Match
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Colorful, interchangeable shells allow you to switch one out to suit your mood. Remove the shell to charge.
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | GET YOUR ROBOT
45 | Sphero Mini fits a huge experience into a tiny robot the size of a ping pong ball. It’s fun, ok?
46 |
47 | BUY MINI
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | GET EXTRA SHELLS
61 | Neon pink, orange, blue, green or classic Sphero white. Buy one. Or two. Or all.
62 |
63 | BUY SHELLS
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/data/complex/onepage.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [[HEADLINE]]
6 |
7 |
8 | [[PERMALINK_LABEL]]
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Home Features
17 | Portfolio
18 |
19 |
20 |
21 |
22 | More than an email template
Only on Mailjet template builder
23 | SUBSCRIBE
25 |
26 |
27 |
28 |
29 |
30 | Best audience
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
31 |
32 |
33 |
34 | Higher rates
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
35 |
36 |
37 |
38 | 24/7 Support
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eleifend sagittis nunc, et fermentum est ullamcorper dignissim.
39 |
40 |
41 |
42 |
43 | Why choose us?
44 |
45 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Lorem ipsum dolor sit amet, consectetur adipiscing
46 | elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | Great newsletter for the best company out there
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
55 | aliqua. Ut enim ad minim veniam.
56 | READ MORE
58 |
59 |
60 |
61 |
62 |
63 | [[DELIVERY_INFO]]
64 |
65 |
66 | [[POSTAL_ADDRESS]]
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentImage.java:
--------------------------------------------------------------------------------
1 | package ch.digitalfondue.mjml4j;
2 |
3 | import static ch.digitalfondue.mjml4j.AttributeValueType.of;
4 | import static ch.digitalfondue.mjml4j.Utils.*;
5 | import static java.util.Map.entry;
6 |
7 | import ch.digitalfondue.mjml4j.AttributeValueType.AttributeType;
8 | import java.util.LinkedHashMap;
9 | import org.w3c.dom.Element;
10 |
11 | class MjmlComponentImage extends BaseComponent.BodyComponent {
12 |
13 | MjmlComponentImage(Element element, BaseComponent parent, GlobalContext context) {
14 | super(element, parent, context);
15 | }
16 |
17 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES =
18 | mapOf(
19 | entry("alt", of("")),
20 | entry("href", of(null)),
21 | entry("name", of(null)),
22 | entry("src", of(null)),
23 | entry("srcset", of(null)),
24 | entry("sizes", of(null)),
25 | entry("title", of(null)),
26 | entry("rel", of(null)),
27 | entry("align", of("center")),
28 | entry("border", of("0")),
29 | entry("border-bottom", of(null)),
30 | entry("border-left", of(null)),
31 | entry("border-right", of(null)),
32 | entry("border-top", of(null)),
33 | entry("border-radius", of(null)),
34 | entry("container-background-color", of(null, AttributeType.COLOR)),
35 | entry("fluid-on-mobile", of(null)),
36 | entry("padding", of("10px 25px")),
37 | entry("padding-bottom", of(null)),
38 | entry("padding-left", of(null)),
39 | entry("padding-right", of(null)),
40 | entry("padding-top", of(null)),
41 | entry("target", of("_blank")),
42 | entry("width", of(null)),
43 | entry("height", of("auto")),
44 | entry("max-height", of(null)),
45 | entry("font-size", of("13px")),
46 | entry("usemap", of(null)));
47 |
48 | @Override
49 | LinkedHashMap allowedAttributes() {
50 | return ALLOWED_DEFAULT_ATTRIBUTES;
51 | }
52 |
53 | private boolean isFullWidth() {
54 | return hasAttribute("full-width") && getAttribute("full-width").equalsIgnoreCase("full-width");
55 | }
56 |
57 | private String getContentWidth() {
58 | var width =
59 | hasAttribute("width")
60 | ? CssUnitParser.parse(getAttribute("width"))
61 | : CssUnitParser.parse(999999 + "px");
62 |
63 | return doubleToString(Math.min(width.value(), getContainerInnerWidth()));
64 | }
65 |
66 | @Override
67 | void setupStyles(CssStyleLibraries cssStyleLibraries) {
68 | var width = getContentWidth();
69 | var isFullWidth = isFullWidth();
70 |
71 | cssStyleLibraries.add(
72 | "img",
73 | mapOf(
74 | "border", getAttribute("border"),
75 | "border-left", getAttribute("border-left"),
76 | "border-right", getAttribute("border-right"),
77 | "border-top", getAttribute("border-top"),
78 | "border-bottom", getAttribute("border-bottom"),
79 | "border-radius", getAttribute("border-radius"),
80 | "display", "block",
81 | "outline", "none",
82 | "text-decoration", "none",
83 | "height", getAttribute("height"),
84 | "max-height", getAttribute("max-height"),
85 | "min-width", isFullWidth ? "100%" : null,
86 | "width", "100%",
87 | "max-width", isFullWidth ? "100%" : null,
88 | "font-size", getAttribute("font-size")));
89 |
90 | cssStyleLibraries.add("td", mapOf("width", isFullWidth ? null : width + "px"));
91 |
92 | cssStyleLibraries.add(
93 | "table",
94 | mapOf(
95 | "min-width", isFullWidth ? "100%" : null,
96 | "max-width", isFullWidth ? "100%" : null,
97 | "width", isFullWidth ? width : null,
98 | "border-collapse", "collapse",
99 | "border-spacing", "0px"));
100 | }
101 |
102 | @Override
103 | String headStyle() {
104 | return " @media only screen and (max-width:"
105 | + makeLowerBreakpoint(context.breakpoint)
106 | + ") {\n"
107 | + " table.mj-full-width-mobile {\n"
108 | + " width: 100% !important;\n"
109 | + " }\n\n"
110 | + " td.mj-full-width-mobile {\n"
111 | + " width: auto !important;\n"
112 | + " }\n"
113 | + " }\n";
114 | }
115 |
116 | private void renderImage(HtmlRenderer renderer, StringBuilder res) {
117 | var bHasHeight = hasAttribute("height");
118 | var height = getAttribute("height");
119 | var hasHref = hasAttribute("href");
120 | if (hasHref) {
121 | renderer.openTag(
122 | "a",
123 | htmlAttributes(
124 | mapOf(
125 | "href", getAttribute("href"),
126 | "target", getAttribute("target"),
127 | "rel", getAttribute("rel"),
128 | "name", getAttribute("name"))),
129 | res);
130 | }
131 | renderer.openCloseTag(
132 | "img",
133 | htmlAttributes(
134 | mapOf(
135 | "alt", getAttribute("alt"),
136 | "src", getAttribute("src"),
137 | "srcset", getAttribute("srcset"),
138 | "sizes", getAttribute("sizes"),
139 | "style", "img",
140 | "title", getAttribute("title"),
141 | "width", getContentWidth(),
142 | "usemap", getAttribute("usemap"),
143 | "height",
144 | bHasHeight && height.equalsIgnoreCase("auto")
145 | ? height
146 | : doubleToString(CssUnitParser.parse(height).value()))),
147 | res);
148 | if (hasHref) {
149 | renderer.closeTag("a", res);
150 | }
151 | }
152 |
153 | @Override
154 | StringBuilder renderMjml(HtmlRenderer renderer) {
155 | var bIsFluidMobile = hasAttribute("fluid-on-mobile");
156 | var res = new StringBuilder();
157 | renderer.openTag(
158 | "table",
159 | htmlAttributes(
160 | mapOf(
161 | "border", "0",
162 | "cellpadding", "0",
163 | "cellspacing", "0",
164 | "role", "presentation",
165 | "style", "table",
166 | "class", bIsFluidMobile ? "mj-full-width-mobile" : null)),
167 | res);
168 | renderer.openTag("tbody", res);
169 | renderer.openTag("tr", res);
170 | renderer.openTag(
171 | "td",
172 | htmlAttributes(
173 | mapOf("style", "td", "class", bIsFluidMobile ? "mj-full-width-mobile" : null)),
174 | res);
175 | renderImage(renderer, res);
176 | renderer.closeTag("td", res);
177 | renderer.closeTag("tr", res);
178 | renderer.closeTag("tbody", res);
179 | renderer.closeTag("table", res);
180 | return res;
181 | }
182 | }
183 |
--------------------------------------------------------------------------------