├── data ├── compiled │ └── .keep ├── base │ ├── base.mjml │ ├── entities-escaping.mjml │ ├── body-width-override.mjml │ ├── raw-special.mjml │ ├── column-gutter.mjml │ ├── attributes.mjml │ └── column-empty-node-social-empty.mjml ├── components │ ├── include │ │ ├── import-head-styles.mjml │ │ ├── type.css │ │ ├── type2.css │ │ ├── type.html │ │ ├── header.mjml │ │ ├── test-partial.mjml │ │ ├── test-partial-two.mjml │ │ ├── footer.mjml │ │ └── styling.mjml │ ├── section-empty.mjml │ ├── preview.mjml │ ├── hero-divider.mjml │ ├── include-head.mjml │ ├── section.mjml │ ├── divider.mjml │ ├── section-with-css-class.mjml │ ├── section-with-background-url.mjml │ ├── section-with-background.mjml │ ├── title.mjml │ ├── breakpoint.mjml │ ├── include-type-html.mjml │ ├── section-column-column-divider.mjml │ ├── divider-all.mjml │ ├── section-with-mj-class.mjml │ ├── font.mjml │ ├── hero-fluid-height-button.mjml │ ├── spacer.mjml │ ├── section-column-precision-2.mjml │ ├── style.mjml │ ├── section-group-column-text.mjml │ ├── include-about.mjml │ ├── include-type-css.mjml │ ├── navbar.mjml │ ├── image.mjml │ ├── section-with-full-width.mjml │ ├── include-2-include.mjml │ ├── include-index.mjml │ ├── section-column-column-section-column-column-divider.mjml │ ├── text.mjml │ ├── text-color.mjml │ ├── button.mjml │ ├── section-column-precision.mjml │ ├── social.mjml │ ├── accordion-permutation.mjml │ ├── social-vertical.mjml │ ├── section-background-image.mjml │ ├── group.mjml │ ├── carousel.mjml │ ├── wrapper.mjml │ ├── table.mjml │ ├── section-with-size.mjml │ ├── accordion.mjml │ ├── accordion-attrs-all.mjml │ └── accordion-attrs.mjml ├── invalid │ ├── readme.txt │ └── circular │ │ ├── circular-1.mjml │ │ ├── circular-2.mjml │ │ └── include-circular.mjml ├── bugs │ ├── mj-column-inner-border.mjml │ ├── social-inner-padding.mjml │ ├── mj-social.mjml │ └── section-border-none.mjml ├── upstream │ ├── column-border-radius.mjml │ ├── wrapper-border-radius.mjml │ ├── social-align.mjml │ ├── social-icon-height.mjml │ ├── html-comments.mjml │ ├── carousel-hoverSupported.mjml │ ├── navbar-ico-padding.mjml │ ├── table-cellspacing.mjml │ ├── wrapper-gap.mjml │ ├── accordion-padding.mjml │ ├── accordion-font-family.mjml │ ├── accordionTitle-fontWeight.mjml │ └── tableWidth.mjml └── complex │ ├── referral-email.mjml │ ├── sphero-droids.mjml │ ├── welcome-email.mjml │ ├── proof.mjml │ ├── basic.mjml │ ├── newsletter.mjml │ ├── reactivation-email.mjml │ ├── black-friday.mjml │ ├── card.mjml │ ├── receipt-email.mjml │ ├── sphero-mini.mjml │ └── onepage.mjml ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── .editorconfig ├── src ├── main │ └── java │ │ ├── module-info.java │ │ └── ch │ │ └── digitalfondue │ │ └── mjml4j │ │ ├── CssBoxModel.java │ │ ├── MjmlComponentHead.java │ │ ├── MjmlComponent.java │ │ ├── CssStyleLibraries.java │ │ ├── MjmlComponentHeadTitle.java │ │ ├── MjmlComponentHeadPreview.java │ │ ├── LocalContext.java │ │ ├── MjmlComponentHeadBreakpoint.java │ │ ├── MjmlComponentHeadFont.java │ │ ├── MjmlComponentHeadStyle.java │ │ ├── MjmlComponentRaw.java │ │ ├── AttributeValueType.java │ │ ├── CssUnitParser.java │ │ ├── HtmlRenderer.java │ │ ├── MjmlComponentSpacer.java │ │ ├── MjmlComponentBody.java │ │ ├── MjmlComponentWrapper.java │ │ ├── MjmlComponentHeadAttributes.java │ │ ├── HtmlComponent.java │ │ ├── MjmlComponentText.java │ │ ├── MjmlComponentAccordionText.java │ │ ├── MjmlComponentTable.java │ │ ├── GlobalContext.java │ │ ├── MjmlComponentAccordionElement.java │ │ ├── MjmlComponentDivider.java │ │ ├── MjmlComponentNavbarLink.java │ │ ├── MjmlComponentAccordion.java │ │ ├── MjmlComponentSocial.java │ │ ├── MjmlComponentAccordionTitle.java │ │ ├── DOMSerializer.java │ │ └── MjmlComponentImage.java └── test │ └── java │ ├── module-info.java │ └── ch │ └── digitalfondue │ └── mjml4j │ ├── testutils │ ├── MjmlDirectory.java │ ├── MjmlListArgumentsProvider.java │ └── Helpers.java │ ├── RenderingTests.java │ ├── FileSystemResolverTest.java │ └── UtilsTest.java ├── package.json ├── .gitignore ├── .github └── workflows │ ├── maven.yml │ └── release.yml ├── LICENSE.txt └── README.md /data/compiled/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/base/base.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/components/include/import-head-styles.mjml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digitalfondue/mjml4j/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /data/components/include/type.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | } 4 | 5 | .my-custom-class { 6 | border: 1px solid red; 7 | } -------------------------------------------------------------------------------- /data/components/include/type2.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | color: green; 3 | } 4 | 5 | .my-custom-class-2 { 6 | border: 1px solid green; 7 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.java] 2 | end_of_line = lf 3 | charset = utf-8 4 | indent_style = space 5 | indent_size = 2 6 | max_line_length = 100 7 | tab_width = 2 -------------------------------------------------------------------------------- /data/invalid/readme.txt: -------------------------------------------------------------------------------- 1 | Put any mjml files that are not expected to compile in this directory so that they can be skipped by the nodejs 2 | mjml compiler -------------------------------------------------------------------------------- /data/components/section-empty.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/components/preview.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello MJML 4 | 5 | 6 | -------------------------------------------------------------------------------- /data/components/hero-divider.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/main/java/module-info.java: -------------------------------------------------------------------------------- 1 | module ch.digitalfondue.mjml4j { 2 | requires ch.digitalfondue.jfiveparse; 3 | requires java.xml; 4 | 5 | exports ch.digitalfondue.mjml4j; 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "mjml": "4.18.0" 4 | }, 5 | "scripts": { 6 | "build":"mjml data/**/*.mjml --config.beautify false -o data/compiled/" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /data/invalid/circular/circular-1.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/invalid/circular/circular-2.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/CssBoxModel.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | record CssBoxModel(double totalWidth, double borderWidth, double paddingWidth, double boxWidth) {} 4 | -------------------------------------------------------------------------------- /data/components/include/type.html: -------------------------------------------------------------------------------- 1 |
hello world!
2 |
3 | 4 | 5 |
ab
6 | 7 | -------------------------------------------------------------------------------- /data/components/include-head.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | test 7 | 8 | -------------------------------------------------------------------------------- /data/components/include/header.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /data/components/section.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /data/components/divider.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/components/section-with-css-class.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | some text 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/components/section-with-background-url.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | some text 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/components/section-with-background.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Foo Bar 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/bugs/mj-column-inner-border.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | hello 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /data/bugs/social-inner-padding.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /data/base/entities-escaping.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <script>alert('test');</script> 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /data/upstream/column-border-radius.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello World 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/base/body-width-override.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | test 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/base/raw-special.mjml: -------------------------------------------------------------------------------- 1 | 2 | 0 3 | 4 | 1 5 | 2 6 | 0-1 7 | 8 | 9 | 10 | 3 11 | 0-2 12 | -------------------------------------------------------------------------------- /data/components/title.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello MJML 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/components/breakpoint.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/upstream/wrapper-border-radius.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello World 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /data/components/include-type-html.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /data/components/section-column-column-divider.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /data/base/column-gutter.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aaa 6 | 7 | 8 | bbb 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /data/components/divider-all.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/invalid/circular/include-circular.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

This is the Index!

8 |
9 |
10 |
11 | 12 | 13 |
14 |
-------------------------------------------------------------------------------- /data/upstream/social-align.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Facebook 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /data/upstream/social-icon-height.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Facebook 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /data/components/section-with-mj-class.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | some text 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /data/components/font.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Hello World! 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/components/hero-fluid-height-button.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ORDER YOUR TICKET NOW 5 | 6 | 7 | -------------------------------------------------------------------------------- /data/upstream/html-comments.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

View source to see comments below

7 | 8 |
9 | 10 |
11 | 12 |
13 |
14 |
15 |
16 |
-------------------------------------------------------------------------------- /data/components/spacer.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A first line of text 6 | 7 | A second line of text 8 | 9 | A third line of text 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | .classpath 3 | .project 4 | .settings/ 5 | .idea/ 6 | .gradle/ 7 | build/ 8 | data/compiled/*.html 9 | *.iml 10 | *.ipr 11 | *.iws 12 | /build/ 13 | deploy*.sh 14 | custom.jvmargs 15 | application-dev.properties 16 | classes/ 17 | logs/ 18 | /bin/ 19 | .DS_Store 20 | out/ 21 | .clever.json 22 | !/src/main/webapp/resources/bower_components/angular-growl-v2/build/ 23 | alfio-itest 24 | .gradletasknamecache 25 | public/ 26 | node_modules/ 27 | lsp 28 | node/ 29 | -------------------------------------------------------------------------------- /data/components/include/test-partial.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Include 1 is here. It's red. 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /data/components/section-column-precision-2.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | hello 6 | 7 | 8 | world 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /data/components/style.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | .red-text div { 5 | color: red !important; 6 | text-decoration: underline !important; 7 | } 8 | 9 | 10 | 11 | 12 | 13 | I'm red and underlined 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /data/components/include/test-partial-two.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Test include 2 is here. It's blue. 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /data/upstream/carousel-hoverSupported.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/test/java/module-info.java: -------------------------------------------------------------------------------- 1 | module ch.digitalfondue.mjml4j { 2 | requires ch.digitalfondue.jfiveparse; 3 | requires java.xml; 4 | 5 | exports ch.digitalfondue.mjml4j; 6 | 7 | // Test extensions 8 | requires org.junit.jupiter.api; 9 | requires org.junit.jupiter.params; 10 | requires org.graalvm.polyglot; 11 | 12 | opens ch.digitalfondue.mjml4j to 13 | org.junit.platform.commons; 14 | 15 | exports ch.digitalfondue.mjml4j.testutils to 16 | org.junit.platform.commons; 17 | } 18 | -------------------------------------------------------------------------------- /data/components/section-group-column-text.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | [[HEADLINE]] 7 | 8 | 9 | [PERMALINK_LABEL]] 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/components/include-about.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

This is the About!

10 |
11 |
12 |
13 | 14 | 15 |
16 |
-------------------------------------------------------------------------------- /data/components/include-type-css.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /data/components/navbar.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Link1 7 | Link2 8 | Link3 9 | 10 | 11 | -------------------------------------------------------------------------------- /data/bugs/mj-social.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Mastodon 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentHead.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.LinkedHashMap; 4 | import org.w3c.dom.Element; 5 | 6 | class MjmlComponentHead extends BaseComponent.HeadComponent { 7 | 8 | MjmlComponentHead(Element element, BaseComponent parent, GlobalContext context) { 9 | super(element, parent, context); 10 | } 11 | 12 | @Override 13 | LinkedHashMap allowedAttributes() { 14 | return Utils.EMPTY_MAP; 15 | } 16 | 17 | @Override 18 | void handler() {} 19 | } 20 | -------------------------------------------------------------------------------- /data/upstream/navbar-ico-padding.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Getting started 7 | Try it live 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /data/base/attributes.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Hello World! 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponent.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.LinkedHashMap; 4 | import org.w3c.dom.Element; 5 | 6 | class MjmlComponent { 7 | 8 | // root component 9 | static class MjmlRootComponent extends BaseComponent.BodyComponent { 10 | 11 | MjmlRootComponent(Element element, BaseComponent parent, GlobalContext context) { 12 | super(element, parent, context); 13 | } 14 | 15 | @Override 16 | LinkedHashMap allowedAttributes() { 17 | return Utils.EMPTY_MAP; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /data/components/image.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/test/java/ch/digitalfondue/mjml4j/testutils/MjmlDirectory.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j.testutils; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | import org.junit.jupiter.params.provider.ArgumentsSource; 8 | 9 | @Target(ElementType.METHOD) 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @ArgumentsSource(MjmlListArgumentsProvider.class) 12 | public @interface MjmlDirectory { 13 | /** The directory to scan for MJML test files. */ 14 | String value(); 15 | } 16 | -------------------------------------------------------------------------------- /data/components/section-with-full-width.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | some text 6 | 7 | 8 | 9 | 10 | some text 11 | 12 | 13 | 14 | 15 | some text 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /data/base/column-empty-node-social-empty.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /data/components/include-2-include.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Basic column here. It's green. 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /data/components/include/footer.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Cheers,

7 |

Me!

8 |
9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |

Something.com ©️ 2023

20 |
21 |
22 |
-------------------------------------------------------------------------------- /data/bugs/section-border-none.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | This section should have no border. 12 | 13 | 14 | 15 | 16 | 17 | 18 | This section should have left and right borders. 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /data/components/include-index.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

This is the Index!

11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 |
-------------------------------------------------------------------------------- /data/components/section-column-column-section-column-column-divider.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/CssStyleLibraries.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.LinkedHashMap; 4 | 5 | class CssStyleLibraries { 6 | 7 | private final LinkedHashMap> libraries = 8 | new LinkedHashMap<>(); 9 | 10 | void add(String libraryName, LinkedHashMap libraryStyles) { 11 | libraries.put(libraryName, libraryStyles); 12 | } 13 | 14 | LinkedHashMap getStyleLibrary(String libraryName) { 15 | if (libraries.containsKey(libraryName)) { 16 | return libraries.get(libraryName); 17 | } 18 | 19 | var toAdd = new LinkedHashMap(); 20 | add(libraryName, toAdd); 21 | 22 | return toAdd; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /data/components/text.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello MJML 6 | Hello MJML 7 |

Hello MJML

8 | Hello

MJML
9 | Hello ’MJML’ 10 | 11 |

Hello MJML

12 |
13 | This should respect whitespaces. after the HTML Tags 14 | Hello >> World 15 |
16 |
17 |
18 |
-------------------------------------------------------------------------------- /data/upstream/table-cellspacing.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Year 8 | Language 9 | Inspired from 10 | 11 | 12 | 1995 13 | PHP 14 | C, Shell Unix 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /data/upstream/wrapper-gap.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Section 1 7 | 8 | 9 | 10 | 11 | Section 2 12 | 13 | 14 | 15 | 16 | Section 3 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /data/components/text-color.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ticketReservation.fullName}}<{{ticketReservation.email}}> has completed the reservation {{reservationShortID}} for {{purchaseContext.displayName}} 7 | 8 | 9 | {{ticketReservation.fullName}}<{{ticketReservation.email}}> has completed the reservation {{reservationShortID}} for {{purchaseContext.displayName}} 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /data/upstream/accordion-padding.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Why use an accordion? 8 | 9 | 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. 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentHeadTitle.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.LinkedHashMap; 4 | import org.w3c.dom.Element; 5 | 6 | class MjmlComponentHeadTitle extends BaseComponent.HeadComponent { 7 | 8 | MjmlComponentHeadTitle(Element element, BaseComponent parent, GlobalContext context) { 9 | super(element, parent, context); 10 | } 11 | 12 | @Override 13 | LinkedHashMap allowedAttributes() { 14 | return Utils.EMPTY_MAP; 15 | } 16 | 17 | @Override 18 | void handler() { 19 | var content = getContent(); 20 | if (!Utils.isNullOrWhiteSpace(content)) context.title = content; 21 | } 22 | 23 | @Override 24 | StringBuilder renderMjml(HtmlRenderer renderer) { 25 | handler(); 26 | return new StringBuilder(0); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /data/components/button.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Button 7 | 8 | 9 | Button Link 10 | 11 | 13 | Button Link 14 | 15 | 16 | Button 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentHeadPreview.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.LinkedHashMap; 4 | import org.w3c.dom.Element; 5 | 6 | class MjmlComponentHeadPreview extends BaseComponent.HeadComponent { 7 | 8 | MjmlComponentHeadPreview(Element element, BaseComponent parent, GlobalContext context) { 9 | super(element, parent, context); 10 | } 11 | 12 | @Override 13 | LinkedHashMap allowedAttributes() { 14 | return Utils.EMPTY_MAP; 15 | } 16 | 17 | @Override 18 | void handler() { 19 | var content = getContent(); 20 | 21 | if (!Utils.isNullOrWhiteSpace(content)) { 22 | context.previewText = content; 23 | } 24 | } 25 | 26 | @Override 27 | StringBuilder renderMjml(HtmlRenderer renderer) { 28 | handler(); 29 | return new StringBuilder(0); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /data/components/section-column-precision.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /data/components/social.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Facebook 13 | 14 | 15 | Google 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/LocalContext.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | record LocalContext( 4 | String accordionFontFamily, String elementFontFamily, String thumbnails, String gap) { 5 | LocalContext() { 6 | this(null, null, null, null); 7 | } 8 | 9 | LocalContext withAccordionFontFamily(String accordionFontFamily) { 10 | return new LocalContext(accordionFontFamily, elementFontFamily, thumbnails, gap); 11 | } 12 | 13 | LocalContext withElementFontFamily(String elementFontFamily) { 14 | return new LocalContext(accordionFontFamily, elementFontFamily, thumbnails, gap); 15 | } 16 | 17 | LocalContext withThumbnails(String thumbnails) { 18 | return new LocalContext(accordionFontFamily, elementFontFamily, thumbnails, gap); 19 | } 20 | 21 | LocalContext withGap(String gap) { 22 | return new LocalContext(accordionFontFamily, elementFontFamily, thumbnails, gap); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /data/components/accordion-permutation.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Why use an accordion? 8 | text 9 | 10 | 11 | Why use an accordion? 12 | 13 | 14 | text 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /data/components/social-vertical.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Facebook 8 | 9 | 10 | Google 11 | 12 | 13 | Twitter 14 | 15 | 16 | X 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /data/components/section-background-image.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | February Newsletter 6 | CTA 7 | 8 | 9 | 10 | February Newsletter 11 | CTA 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentHeadBreakpoint.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 org.w3c.dom.Element; 8 | 9 | class MjmlComponentHeadBreakpoint extends BaseComponent.HeadComponent { 10 | 11 | MjmlComponentHeadBreakpoint(Element element, BaseComponent parent, GlobalContext context) { 12 | super(element, parent, context); 13 | } 14 | 15 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES = 16 | mapOf("width", of(null)); 17 | 18 | @Override 19 | LinkedHashMap allowedAttributes() { 20 | return ALLOWED_DEFAULT_ATTRIBUTES; 21 | } 22 | 23 | @Override 24 | void handler() { 25 | if (hasAttribute("width")) { 26 | context.breakpoint = getAttribute("width"); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /data/components/include/styling.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | .bg-dark { 6 | background: #2F5854 !important; 7 | } 8 | .bg-white { 9 | background: #FFFFFF !important; 10 | } 11 | p { 12 | font-size: 16px !important; 13 | font-weight: 400 !important; 14 | line-height: 24px !important; 15 | margin: 0; 16 | } 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentHeadFont.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 org.w3c.dom.Element; 8 | 9 | class MjmlComponentHeadFont extends BaseComponent.HeadComponent { 10 | 11 | MjmlComponentHeadFont(Element element, BaseComponent parent, GlobalContext context) { 12 | super(element, parent, context); 13 | } 14 | 15 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES = 16 | mapOf( 17 | "name", of(null), 18 | "href", of(null)); 19 | 20 | @Override 21 | LinkedHashMap allowedAttributes() { 22 | return ALLOWED_DEFAULT_ATTRIBUTES; 23 | } 24 | 25 | @Override 26 | void handler() { 27 | if (hasAttribute("name") && hasAttribute("href")) { 28 | context.addFont(getAttribute("name"), getAttribute("href")); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/ch/digitalfondue/mjml4j/testutils/MjmlListArgumentsProvider.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j.testutils; 2 | 3 | import java.io.IOException; 4 | import java.nio.file.Files; 5 | import java.nio.file.Path; 6 | import java.util.stream.Stream; 7 | import org.junit.jupiter.api.extension.ExtensionContext; 8 | import org.junit.jupiter.params.provider.AnnotationBasedArgumentsProvider; 9 | import org.junit.jupiter.params.provider.Arguments; 10 | 11 | public class MjmlListArgumentsProvider extends AnnotationBasedArgumentsProvider { 12 | @Override 13 | protected Stream provideArguments( 14 | ExtensionContext context, MjmlDirectory annotation) { 15 | try { 16 | return Files.list(Path.of("data", annotation.value())) 17 | .filter(p -> p.toString().endsWith(".mjml")) 18 | .map(p -> p.getFileName().toString().replace(".mjml", "")) 19 | .map(Arguments::of); 20 | } catch (IOException e) { 21 | throw new RuntimeException(e); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /data/components/group.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

Easy and quick

9 |

Write less code, save time and code more efficiently with MJML’s semantic syntax.

10 |
11 |
12 | 13 | 14 | 15 |

Responsive

16 |

MJML is responsive by design on most-popular email clients, even Outlook.

17 |
18 |
19 |
20 |
21 |
22 |
-------------------------------------------------------------------------------- /data/components/carousel.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Java CI with Maven 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | java: ['17', '21', '25'] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up JDK ${{matrix.java}} 23 | uses: actions/setup-java@v4 24 | with: 25 | java-version: ${{matrix.java}} 26 | distribution: corretto 27 | - name: Maven -v 28 | run: ./mvnw -v 29 | - name: Cache Maven packages 30 | uses: actions/cache@v4 31 | with: 32 | path: ~/.m2 33 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 34 | restore-keys: ${{ runner.os }}-m2 35 | - name: Build with Maven 36 | run: ./mvnw -B package --file pom.xml 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to the Maven Central Repository 2 | on: 3 | push: 4 | tags: 5 | - mjml4j-* 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Java 12 | uses: actions/setup-java@v4 13 | with: 14 | java-version: '17' 15 | distribution: 'corretto' 16 | - name: Publish package 17 | env: 18 | JRELEASER_MAVENCENTRAL_SONATYPE_USERNAME: ${{ secrets.TOKEN_JRELEASER_SONATYPE_USERNAME }} 19 | JRELEASER_MAVENCENTRAL_SONATYPE_TOKEN: ${{ secrets.TOKEN_JRELEASER_SONATYPE_TOKEN }} 20 | JRELEASER_GPG_PASSPHRASE: ${{ secrets.JRELEASER_GPG_PASSPHRASE }} 21 | JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }} 22 | JRELEASER_GPG_PUBLIC_KEY: ${{ secrets.JRELEASER_GPG_PUBLIC_KEY }} 23 | JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | run: ./mvnw -Prelease deploy jreleaser:deploy -DaltDeploymentRepository=local::file:./target/staging-deploy -------------------------------------------------------------------------------- /data/components/wrapper.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | First line of text 14 | 15 | Second line of text 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.3/apache-maven-3.9.3-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 19 | -------------------------------------------------------------------------------- /data/components/table.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Year 8 | Language 9 | Inspired from 10 | 11 | 12 | 1995 13 | PHP 14 | C, Shell Unix 15 | 16 | 17 | 1995 18 | JavaScript 19 | Scheme, Self 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentHeadStyle.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 org.w3c.dom.Element; 8 | 9 | class MjmlComponentHeadStyle extends BaseComponent.HeadComponent { 10 | 11 | MjmlComponentHeadStyle(Element element, BaseComponent parent, GlobalContext context) { 12 | super(element, parent, context); 13 | } 14 | 15 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES = 16 | mapOf("inline", of(null)); 17 | 18 | @Override 19 | LinkedHashMap allowedAttributes() { 20 | return ALLOWED_DEFAULT_ATTRIBUTES; 21 | } 22 | 23 | @Override 24 | void handler() { 25 | var css = getContent(); 26 | if (Utils.isNullOrWhiteSpace(css)) { 27 | return; 28 | } 29 | context.addStyle(css, hasAttribute("inline")); 30 | } 31 | 32 | @Override 33 | StringBuilder renderMjml(HtmlRenderer renderer) { 34 | handler(); 35 | return new StringBuilder(0); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /data/components/section-with-size.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{#walletEnabled}} 4 | 5 | {{#googleWalletEnabled}} 6 | 7 | 8 | 9 | {{/googleWalletEnabled}} 10 | {{#appleWalletEnabled}} 11 | 12 | 13 | 14 | {{/appleWalletEnabled}} 15 | 16 | {{/walletEnabled}} 17 | 18 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentRaw.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.LinkedHashMap; 4 | import org.w3c.dom.Element; 5 | 6 | class MjmlComponentRaw extends BaseComponent.BodyComponent { 7 | 8 | private final String content; 9 | 10 | MjmlComponentRaw(Element element, BaseComponent parent, GlobalContext context) { 11 | super(element, parent, context); 12 | this.content = null; 13 | } 14 | 15 | MjmlComponentRaw(Element element, BaseComponent parent, GlobalContext context, String content) { 16 | super(element, parent, context); 17 | this.content = content; 18 | } 19 | 20 | @Override 21 | LinkedHashMap allowedAttributes() { 22 | return Utils.EMPTY_MAP; 23 | } 24 | 25 | @Override 26 | StringBuilder renderChildren(HtmlRenderer renderer) { 27 | if (content != null) { 28 | return new StringBuilder(content); 29 | } 30 | var res = new StringBuilder(); 31 | DOMSerializer.serializeInner(getElement(), res); 32 | return res; 33 | } 34 | 35 | @Override 36 | boolean isRawElement() { 37 | return true; 38 | } 39 | 40 | @Override 41 | boolean isEndingTag() { 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mailjet SAS, https://mjml.io 4 | Copyright (c) 2020 Liam Riddell 5 | Copyright (c) 2022 Sebastian Stehle & LiamRiddell 6 | Copyright (c) 2024 Sylvain Jermini 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. -------------------------------------------------------------------------------- /data/upstream/accordion-font-family.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Why use an accordion? 8 | 9 | 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. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Why use an accordion? 20 | 21 | 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. 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/test/java/ch/digitalfondue/mjml4j/RenderingTests.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import static ch.digitalfondue.mjml4j.testutils.Helpers.testTemplate; 4 | 5 | import ch.digitalfondue.mjml4j.testutils.MjmlDirectory; 6 | import org.junit.jupiter.api.Assertions; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.params.ParameterizedTest; 9 | 10 | /** Unit test for base cases. */ 11 | class RenderingTests { 12 | @ParameterizedTest 13 | @MjmlDirectory("base") 14 | void testBaseTemplates(String name) { 15 | testTemplate("base", name); 16 | } 17 | 18 | @ParameterizedTest 19 | @MjmlDirectory("components") 20 | void testComponentTemplates(String name) { 21 | testTemplate("components", name); 22 | } 23 | 24 | @ParameterizedTest 25 | @MjmlDirectory("complex") 26 | void testComplexTemplates(String name) { 27 | testTemplate("complex", name); 28 | } 29 | 30 | @ParameterizedTest 31 | @MjmlDirectory("upstream") 32 | void testUpstreamTemplates(String name) { 33 | testTemplate("upstream", name); 34 | } 35 | 36 | @ParameterizedTest 37 | @MjmlDirectory("bugs") 38 | void testBugsTemplates(String name) { 39 | testTemplate("bugs", name); 40 | } 41 | 42 | @Test 43 | void testCircularInclude() { 44 | var t = 45 | Assertions.assertThrows( 46 | IllegalStateException.class, 47 | () -> testTemplate("invalid/circular", "include-circular")); 48 | Assertions.assertTrue(t.getMessage().startsWith("Circular inclusion detected on file")); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/AttributeValueType.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.regex.Pattern; 4 | 5 | record AttributeValueType(String value, AttributeType type) { 6 | 7 | static AttributeValueType of(String value) { 8 | return new AttributeValueType(value, null); 9 | } 10 | 11 | static AttributeValueType of(String value, AttributeType type) { 12 | return new AttributeValueType(value, type); 13 | } 14 | 15 | String process(String value) { 16 | return type != null ? type.process(value) : value; 17 | } 18 | 19 | private static final Pattern COLOR_SHORT = Pattern.compile("^#(\\w)(\\w)(\\w)$"); 20 | 21 | enum AttributeType { 22 | ALIGN, 23 | ALIGN_JUSTIFY, 24 | BOOLEAN, 25 | COLOR { 26 | @Override 27 | String process(String value) { 28 | if (value != null) { 29 | var matcher = COLOR_SHORT.matcher(value); 30 | if (matcher.find()) { 31 | return "#" 32 | + matcher.group(1).repeat(2) 33 | + matcher.group(2).repeat(2) 34 | + matcher.group(3).repeat(2); 35 | } 36 | } 37 | return value; 38 | } 39 | }, 40 | DIRECTION, 41 | LEFT_RIGHT, 42 | INCLUDE_TYPE, 43 | PIXELS, 44 | PIXELS_OR_AUTO, 45 | PIXELS_OR_EM, 46 | PIXELS_OR_PERCENT, 47 | PIXELS_OR_PERCENT_OR_NONE, 48 | FOUR_PIXELS_OR_PERCENT, 49 | STRING, 50 | REQUIRED_STRING, 51 | VERTICAL_ALIGN, 52 | SOCIAL_TABLE_LAYOUT, 53 | SOCIAL_MODE, 54 | TEXT_ALIGN; 55 | 56 | String process(String value) { 57 | return value; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/CssUnitParser.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.regex.Pattern; 4 | 5 | class CssUnitParser { 6 | 7 | record CssParsedUnit(String unit, double value, double valueFullPrecision) { 8 | 9 | boolean isPercent() { 10 | return "%".equals(unit); 11 | } 12 | 13 | boolean isPx() { 14 | return "px".equals(unit); 15 | } 16 | 17 | @Override 18 | public String toString() { 19 | return Utils.doubleToString(value) + unit; 20 | } 21 | 22 | public String toFullPrecisionString() { 23 | return Utils.doubleToString(valueFullPrecision) + unit; 24 | } 25 | 26 | CssParsedUnit withValue(double value) { 27 | return new CssParsedUnit(unit, value, value); 28 | } 29 | } 30 | 31 | private static final Pattern UNIT_PATTERN = Pattern.compile("([0-9.,]*)([^0-9]*)$"); 32 | 33 | static CssParsedUnit parse(String cssValue) { 34 | 35 | if (Utils.isNullOrWhiteSpace(cssValue)) { 36 | return new CssParsedUnit(null, 0, 0); 37 | } 38 | 39 | var match = UNIT_PATTERN.matcher(cssValue); 40 | 41 | if (!match.find()) { 42 | throw new IllegalStateException( 43 | "CssWidthParser could not parse " + cssValue + " due to invalid format"); 44 | } 45 | 46 | var widthValue = match.group(1); 47 | var widthUnit = match.groupCount() != 2 ? "px" : match.group(2); 48 | if (Utils.isNullOrWhiteSpace(widthUnit)) { 49 | widthUnit = "px"; 50 | } 51 | 52 | var valueFullPrecision = Double.parseDouble(widthValue); 53 | if ("%".equals(widthUnit)) { 54 | return new CssParsedUnit(widthUnit, valueFullPrecision, valueFullPrecision); 55 | } else { 56 | return new CssParsedUnit(widthUnit, (int) valueFullPrecision, valueFullPrecision); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /data/complex/referral-email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Hey {{FirstName}}! 19 | Are you enjoying our weekly newsletter?
Then why not share it with your friends?
20 | You'll get a 15% discount
21 | on your next order when a friend uses the code {{ReferalCode}}!
22 | Refer a friend now 23 | Best,
The {{CompanyName}} Team 24 |

25 |
26 |
27 |
28 |
29 |
-------------------------------------------------------------------------------- /data/complex/sphero-droids.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Hey {{FirstName}}! 19 | Are you enjoying our weekly newsletter?
Then why not share it with your friends?
20 | You'll get a 15% discount
21 | on your next order when a friend uses the code {{ReferalCode}}!
22 | Refer a friend now 23 | Best,
The {{CompanyName}} Team 24 |

25 |
26 |
27 |
28 |
29 |
-------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/HtmlRenderer.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | class HtmlRenderer { 4 | private int depth; 5 | 6 | void increaseDepth() { 7 | depth++; 8 | } 9 | 10 | void openTag(String name, StringBuilder buffer) { 11 | buffer.append(" ".repeat(Math.max(0, depth))); 12 | buffer.append("<").append(name).append(">\n"); 13 | depth++; 14 | } 15 | 16 | void openTag(String name, StringBuilder attributes, StringBuilder buffer, boolean spaceEnd) { 17 | buffer.append(" ".repeat(Math.max(0, depth))); 18 | buffer.append("<").append(name).append(" ").append(attributes); 19 | if (spaceEnd) { 20 | buffer.append(" "); 21 | } 22 | buffer.append(">\n"); 23 | depth++; 24 | } 25 | 26 | void openTag(String name, StringBuilder attributes, StringBuilder buffer) { 27 | openTag(name, attributes, buffer, true); 28 | } 29 | 30 | void openCloseTag(String name, StringBuilder attributes, StringBuilder buffer) { 31 | 32 | buffer.append(" ".repeat(Math.max(0, depth))); 33 | buffer.append("<").append(name).append(" ").append(attributes).append(" />\n"); 34 | } 35 | 36 | void closeTag(String name, StringBuilder buffer) { 37 | depth--; 38 | buffer.append(" ".repeat(Math.max(0, depth))); 39 | buffer.append("\n"); 40 | } 41 | 42 | void openIfMsoIE(StringBuilder buffer) { 43 | openIfMsoIE(buffer, false); 44 | } 45 | 46 | void openIfMsoIE(StringBuilder buffer, boolean newLine) { 47 | buffer.append(""); 59 | if (newLine) { 60 | buffer.append('\n'); 61 | } 62 | } 63 | 64 | void appendCurrentSpacing(StringBuilder buffer) { 65 | buffer.append(" ".repeat(Math.max(0, depth))); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /data/upstream/accordionTitle-fontWeight.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Why use an accordion? 16 | 17 | 18 | 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. 19 | 20 | 21 | 22 | 23 | How it works 24 | 25 | 26 | 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. 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentSpacer.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 | // see https://github.com/mjmlio/mjml/blob/master/packages/mjml-spacer/src/index.js 12 | class MjmlComponentSpacer extends BaseComponent.BodyComponent { 13 | 14 | MjmlComponentSpacer(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", of(null)), 21 | entry("border-bottom", of(null)), 22 | entry("border-left", of(null)), 23 | entry("border-right", of(null)), 24 | entry("border-top", of(null)), 25 | entry("container-background-color", of(null, AttributeType.COLOR)), 26 | entry("padding-bottom", of(null)), 27 | entry("padding-left", of(null)), 28 | entry("padding-right", of(null)), 29 | entry("padding-top", of(null)), 30 | entry("padding", of(null)), 31 | entry("height", of("20px"))); 32 | 33 | @Override 34 | LinkedHashMap allowedAttributes() { 35 | return ALLOWED_DEFAULT_ATTRIBUTES; 36 | } 37 | 38 | @Override 39 | void setupStyles(CssStyleLibraries cssStyleLibraries) { 40 | cssStyleLibraries.add( 41 | "div", 42 | mapOf( 43 | "height", getAttribute("height"), 44 | "line-height", getAttribute("height"))); 45 | } 46 | 47 | @Override 48 | StringBuilder renderMjml(HtmlRenderer renderer) { 49 | var res = new StringBuilder(); 50 | res.append("
"); 51 | res.append(" "); 52 | res.append("
\n"); 53 | return res; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentBody.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 org.w3c.dom.Element; 9 | 10 | class MjmlComponentBody extends BaseComponent.BodyComponent { 11 | MjmlComponentBody(Element element, BaseComponent parent, GlobalContext context) { 12 | super(element, parent, context); 13 | 14 | if (hasAttribute("width")) { 15 | context.containerWidth = getAttribute("width"); 16 | } 17 | 18 | if (hasAttribute("background-color")) { 19 | context.backgroundColor = getAttribute("background-color"); 20 | } 21 | } 22 | 23 | private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES = 24 | mapOf( 25 | "width", of("600px", AttributeType.PIXELS), 26 | "background-color", of(null, AttributeType.COLOR)); 27 | 28 | @Override 29 | LinkedHashMap allowedAttributes() { 30 | return ALLOWED_DEFAULT_ATTRIBUTES; 31 | } 32 | 33 | @Override 34 | void setupStyles(CssStyleLibraries cssStyleLibraries) { 35 | cssStyleLibraries.add("div", mapOf("background-color", getAttribute("background-color"))); 36 | } 37 | 38 | @Override 39 | StringBuilder renderMjml(HtmlRenderer renderer) { 40 | var res = new StringBuilder(); 41 | var divAttrs = new LinkedHashMap(); 42 | if (!context.title.isEmpty()) { 43 | divAttrs.put("aria-label", context.title); 44 | } 45 | divAttrs.put("aria-roledescription", "email"); 46 | divAttrs.put("class", getAttribute("class")); 47 | divAttrs.put("style", "div"); 48 | divAttrs.put("role", "article"); 49 | divAttrs.put("lang", context.language); 50 | divAttrs.put("dir", context.dir); 51 | renderer.openTag("div", htmlAttributes(divAttrs), res); 52 | res.append(renderChildren(renderer)); 53 | renderer.closeTag("div", res); 54 | return res; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/ch/digitalfondue/mjml4j/MjmlComponentWrapper.java: -------------------------------------------------------------------------------- 1 | package ch.digitalfondue.mjml4j; 2 | 3 | import java.util.LinkedHashMap; 4 | import org.w3c.dom.Element; 5 | 6 | class MjmlComponentWrapper extends MjmlComponentSection { 7 | 8 | MjmlComponentWrapper(Element element, BaseComponent parent, GlobalContext context) { 9 | super(element, parent, context); 10 | } 11 | 12 | @Override 13 | LinkedHashMap allowedAttributes() { 14 | LinkedHashMap 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(""); 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 | + " 93 | 94 |
\n")); 88 | res.append(renderContent()); 89 | res.append( 90 | Utils.conditionalTag( 91 | """ 92 |
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(""); 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 | --------------------------------------------------------------------------------