fonts = Utils.mapOf(
"Open Sans", "https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700",
@@ -39,6 +45,10 @@ class GlobalContext {
this.document = document;
this.dir = configuration.dir().value();
this.language = configuration.language();
+
+ //
+ this.includeResolver = configuration.includeResolver();
+ //
}
void addFont(String name, String href) {
diff --git a/src/main/java/ch/digitalfondue/mjml4j/Mjml4j.java b/src/main/java/ch/digitalfondue/mjml4j/Mjml4j.java
index 9b8ec9d..1d846a2 100644
--- a/src/main/java/ch/digitalfondue/mjml4j/Mjml4j.java
+++ b/src/main/java/ch/digitalfondue/mjml4j/Mjml4j.java
@@ -9,9 +9,11 @@
import org.w3c.dom.Node;
import org.w3c.dom.Text;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.Locale;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -19,10 +21,10 @@
/**
* mjml java implementation.
- *
+ *
* You can use the default methods {@link Mjml4j#render(String)} or for a little bit more control {@link #render(String, Configuration)} if you
* specify the language (recommended).
- *
+ *
* See the record {@link Configuration}.
*/
public final class Mjml4j {
@@ -34,7 +36,8 @@ private Mjml4j() {
public enum TextDirection {
LTR("ltr"), RTL("rtl"), AUTO("auto");
- private String value;
+ private final String value;
+
TextDirection(String value) {
this.value = value;
}
@@ -44,7 +47,113 @@ public String value() {
}
}
- public record Configuration(String language, TextDirection dir) {
+ /**
+ * Resolver used for mj-include. By default, no resource resolver is defined.
+ * See also {@link FileSystemResolver}.
+ */
+ interface IncludeResolver {
+ /**
+ * Read the content of the resource at a given resolved path.
+ *
+ * @param resolvedResourcePath
+ * @return
+ * @throws IOException
+ */
+ String readResource(String resolvedResourcePath) throws IOException;
+
+ /**
+ * Resolve the given path.
+ *
+ * @param name
+ * @param parent
+ * @return
+ */
+ String resolvePath(String name, String parent);
+ }
+
+ @FunctionalInterface
+ public interface ResourceLoader {
+ /**
+ *
+ * @param id resource id
+ * @return loaded resource
+ * @throws IOException if the resource does not exist or a problem during loading happened
+ */
+ String load(String id) throws IOException;
+ }
+
+ /**
+ * Simple resource resolver, does not try to do any fancy relative / absolute handling.
+ * The name of the resource is the identifier used by {@link #readResource(String)}.
+ * The user must provide a resource handler.
+ */
+ public static class SimpleResourceResolver implements IncludeResolver {
+
+ private final ResourceLoader resourceHandler;
+
+ public SimpleResourceResolver(ResourceLoader resourceHandler) {
+ this.resourceHandler = resourceHandler;
+ }
+
+ @Override
+ public String readResource(String resolvedResourcePath) throws IOException {
+ return resourceHandler.load(resolvedResourcePath);
+ }
+
+ @Override
+ public String resolvePath(String name, String parent) {
+ return name;
+ }
+ }
+
+ /**
+ * Filesystem based resolver. The content _must_ be within the provided basePath.
+ *
+ * The basePath will also be considered the root for any absolute paths.
+ *
+ * The check can be customized, see {@link #checkAccess(Path, Path)}.
+ */
+ public static class FileSystemResolver implements IncludeResolver {
+
+ private final Path basePath;
+
+ public FileSystemResolver(Path basePath) {
+ this.basePath = Objects.requireNonNull(basePath).toAbsolutePath();
+ }
+
+ @Override
+ public String readResource(String resolvedResourcePath) throws IOException {
+ return Files.readString(Path.of(resolvedResourcePath), StandardCharsets.UTF_8);
+ }
+
+ public void checkAccess(Path basePath, Path resolvedPath) {
+ if (!resolvedPath.startsWith(basePath)) {
+ throw new IllegalStateException("Cannot access path outside of basePath");
+ }
+ }
+
+
+ @Override
+ public String resolvePath(String name, String parent) {
+ var providedPath = Path.of(name);
+ if (providedPath.isAbsolute()) { // resolve the absolute path over the basePath
+ providedPath = basePath.resolve(providedPath.getRoot().relativize(providedPath));
+ }
+
+ var resolvedPath = (parent == null ? basePath.resolve(providedPath) : Path.of(parent).getParent().resolve(providedPath)).normalize().toAbsolutePath();
+ checkAccess(basePath, resolvedPath);
+ return resolvedPath.toString();
+ }
+ }
+
+ public record Configuration(
+ String language, TextDirection dir,
+ IncludeResolver includeResolver
+ ) {
+ public Configuration(String language, TextDirection dir) {
+ this(language, dir, null);
+ }
+
public Configuration(String language) {
this(language, TextDirection.AUTO);
}
@@ -52,6 +161,23 @@ public Configuration(String language) {
private static final Configuration DEFAULT_CONFIG = new Configuration("und", TextDirection.AUTO);
+
+ private static List parseHtmlFragment(String template) {
+ return JFiveParse.parseFragment(template, EnumSet.of(Option.DISABLE_IGNORE_TOKEN_IN_BODY_START_TAG, Option.INTERPRET_SELF_CLOSING_ANYTHING_ELSE, Option.DONT_TRANSFORM_ENTITIES));
+ }
+
+ private static org.w3c.dom.Document parseMjmlFragment(String template) {
+ //
+ var wrappedFragment = !template.contains("") ? "" + template + "" : template;
+ //
+ var nodes = parseHtmlFragment(wrappedFragment);
+ var doc = new Document();
+ for (var n : nodes) {
+ doc.appendChild(n);
+ }
+ return W3CDom.toW3CDocument(doc);
+ }
+
/**
* Render the given template with the provided configuration.
*
@@ -60,9 +186,7 @@ public Configuration(String language) {
* @return
*/
public static String render(String template, Configuration configuration) {
- var nodes = JFiveParse.parseFragment(template, EnumSet.of(Option.DISABLE_IGNORE_TOKEN_IN_BODY_START_TAG, Option.INTERPRET_SELF_CLOSING_ANYTHING_ELSE, Option.DONT_TRANSFORM_ENTITIES));
-
- var rootElemMaybe = nodes.stream().filter(node -> "mjml".equals(node.getNodeName())).findFirst();
+ var rootElemMaybe = parseHtmlFragment(template).stream().filter(node -> "mjml".equals(node.getNodeName())).findFirst();
if (rootElemMaybe.isEmpty()) {
throw new IllegalStateException("no root element mjml found");
}
@@ -84,22 +208,23 @@ public static String render(String template) {
return render(template, DEFAULT_CONFIG);
}
- private static StringBuilder renderHead(MjmlComponent.MjmlRootComponent rootComponent, HtmlRenderer renderer) {
+ private static Optional findFirstComponent(MjmlComponent.MjmlRootComponent rootComponent, String name) {
return rootComponent
.getChildren()
.stream()
- .filter(c -> c.getTagName().equals("mj-head"))
- .findFirst().map(component -> component.renderMjml(renderer)).orElseGet(StringBuilder::new);
+ .filter(c -> name.equals(c.getTagName()))
+ .findFirst();
+ }
+
+ private static StringBuilder renderHead(MjmlComponent.MjmlRootComponent rootComponent, HtmlRenderer renderer) {
+ return findFirstComponent(rootComponent, "mj-head").map(component -> component.renderMjml(renderer)).orElseGet(StringBuilder::new);
}
private static String renderBody(MjmlComponent.MjmlRootComponent rootComponent, HtmlRenderer renderer) {
renderer.increaseDepth();
- return rootComponent
- .getChildren()
- .stream()
- .filter(c -> c.getTagName().equals("mj-body"))
- .findFirst().map(component -> component.renderMjml(renderer).toString())
+ return findFirstComponent(rootComponent, "mj-body")
+ .map(component -> component.renderMjml(renderer).toString())
.orElse("");
}
@@ -302,8 +427,15 @@ private static void buildFontsTags(String content, GlobalContext context, String
private static MjmlComponent.MjmlRootComponent buildMjmlDocument(org.w3c.dom.Document document, GlobalContext context) {
var root = (Element) document.getElementsByTagName("mjml").item(0);
var rootComponent = new MjmlComponent.MjmlRootComponent(root, null, context);
- traverseTree(rootComponent.getElement(), rootComponent, document, context);
- rootComponent.doSetupPostConstruction();
+ context.rootComponents.push(rootComponent);
+ try {
+ traverseTree(rootComponent.getElement(), rootComponent, document, context);
+ } finally {
+ context.rootComponents.pop();
+ }
+ if (context.rootComponents.isEmpty()) {
+ rootComponent.doSetupPostConstruction();
+ }
return rootComponent;
}
@@ -372,6 +504,7 @@ private static BaseComponent createMjmlComponent(Element element, BaseComponent
case "mj-divider" -> new MjmlComponentDivider(element, parent, context);
case "mj-raw" -> new MjmlComponentRaw(element, parent, context);
case "mj-image" -> new MjmlComponentImage(element, parent, context);
+ case "mj-include" -> handleInclude(element, parent, context);
case "mj-button" -> new MjmlComponentButton(element, parent, context);
case "mj-hero" -> new MjmlComponentHero(element, parent, context);
case "mj-social" -> new MjmlComponentSocial(element, parent, context);
@@ -390,4 +523,61 @@ private static BaseComponent createMjmlComponent(Element element, BaseComponent
default -> new HtmlComponent.HtmlRawComponent(element, parent, context);
};
}
+
+ private static BaseComponent handleInclude(Element element, BaseComponent parent, GlobalContext context) {
+ var path = element.getAttribute("path");
+ if (context.includeResolver == null || path == null || path.isEmpty()) {
+ return new HtmlComponent.HtmlRawComponent(element, parent, context);
+ }
+
+ var includeResolver = context.includeResolver;
+ var resolvedPath = "";
+ var resource = "";
+ try {
+ resolvedPath = includeResolver.resolvePath(path, context.currentResourcePaths.peek());
+ resource = includeResolver.readResource(resolvedPath);
+ if (context.currentResourcePaths.contains(resolvedPath)) {
+ throw new IllegalStateException("Circular inclusion detected on file : " + resolvedPath);
+ }
+ } catch (IOException e) {
+ resource = "";
+ return new MjmlComponentRaw(element, parent, context, resource);
+ }
+
+ var attributeType = element.getAttribute("type");
+ if ("html".equals(attributeType)) {
+ return new MjmlComponentRaw(element, parent, context, resource);
+ } else if ("css".equals(attributeType)) {
+ context.addStyle(resource, "inline".equals(element.getAttribute("css-inline")));
+ return new MjmlComponentRaw(element, parent, context, ""); // dummy empty component
+ } else {
+ context.currentResourcePaths.push(resolvedPath);
+ try {
+ var doc = parseMjmlFragment(resource);
+ var includedDoc = buildMjmlDocument(doc, context);
+ findFirstComponent(includedDoc, "mj-head").ifPresent(head -> {
+ var targetRoot = Objects.requireNonNull(context.rootComponents.peekLast());
+ var targetHead = findFirstComponent(targetRoot, "mj-head").orElseGet(() -> {
+ var headToAppend = new MjmlComponentHead(context.document.createElement("mj-head"), targetRoot, context);
+ targetRoot.getChildren().add(0, headToAppend);
+ return headToAppend;
+ });
+ bindToParent(targetHead, head.getChildren());
+ });
+ findFirstComponent(includedDoc, "mj-body").ifPresent(body -> {
+ bindToParent(parent, body.getChildren());
+ });
+ return new MjmlComponentRaw(element, parent, context, ""); // dummy empty component
+ } finally {
+ context.currentResourcePaths.pop();
+ }
+ }
+ }
+
+ private static void bindToParent(BaseComponent parent, List children) {
+ for (var c : children) {
+ c.setParent(parent);
+ parent.getChildren().add(c);
+ }
+ }
}
diff --git a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentBody.java b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentBody.java
index 4455f50..b4a9b90 100644
--- a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentBody.java
+++ b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentBody.java
@@ -21,11 +21,6 @@ class MjmlComponentBody extends BaseComponent.BodyComponent {
}
}
- @Override
- void setupPostConstruction() {
- super.setupPostConstruction();
- }
-
private static final LinkedHashMap ALLOWED_DEFAULT_ATTRIBUTES = mapOf(
"width", of("600px", AttributeType.PIXELS),
"background-color", of(null, AttributeType.COLOR)
diff --git a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentButton.java b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentButton.java
index a2e1e78..8631437 100644
--- a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentButton.java
+++ b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentButton.java
@@ -114,7 +114,7 @@ private String calculateAWidth(String content) {
var innerPaddings = getShorthandAttributeValue("inner-padding", "left") +
getShorthandAttributeValue("inner-padding", "right");
- var calculatedWidth = parsedWidth.value - innerPaddings - borders;
+ var calculatedWidth = parsedWidth.value() - innerPaddings - borders;
return doubleToString(calculatedWidth) + "px";
}
diff --git a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarousel.java b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarousel.java
index 448b764..3261c27 100644
--- a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarousel.java
+++ b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarousel.java
@@ -275,7 +275,9 @@ void setupStyles(CssStyleLibraries cssStyleLibraries) {
private StringBuilder generateRadios(HtmlRenderer renderer) {
var res = new StringBuilder();
- carouselImages.forEach(c -> res.append(c.renderRadio(renderer)));
+ for (var ci : carouselImages) {
+ res.append(ci.renderRadio(renderer));
+ }
return res;
}
@@ -284,7 +286,9 @@ private StringBuilder generateThumbnails(HtmlRenderer renderer) {
if (!"visible".equals(getAttribute("thumbnails"))) {
return res;
}
- carouselImages.forEach(c -> res.append(c.renderThumbnail(renderer)));
+ for (var c : carouselImages) {
+ res.append(c.renderThumbnail(renderer));
+ }
return res;
}
@@ -298,7 +302,7 @@ private String getThumbnailsWidth() {
return tbWidth;
}
var containerWidth = CssUnitParser.parse(doubleToString(getContainerOuterWidth()));
- return doubleToString(Math.min(containerWidth.value, 110));
+ return doubleToString(Math.min(containerWidth.value(), 110));
}
@Override
@@ -384,9 +388,8 @@ private StringBuilder generateControls(String direction, String icon, HtmlRender
}
private StringBuilder renderFallback(HtmlRenderer renderer) {
- var res = new StringBuilder();
if (carouselImagesCount == 0) {
- return res;
+ return new StringBuilder(0);
}
return new StringBuilder(msoConditionalTag(carouselImages.get(0).renderMjml(renderer).toString()));
}
diff --git a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarouselImage.java b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarouselImage.java
index 54d8786..cb451e6 100644
--- a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarouselImage.java
+++ b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentCarouselImage.java
@@ -98,7 +98,7 @@ StringBuilder renderThumbnail(HtmlRenderer renderer) {
"style", "thumbnails_img",
"src", src,
"alt", getAttribute("alt"),
- "width", doubleToString(CssUnitParser.parse(getAttribute("tb-width")).value)
+ "width", doubleToString(CssUnitParser.parse(getAttribute("tb-width")).value())
)), res);
renderer.closeTag("label", res);
diff --git a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentColumn.java b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentColumn.java
index 3497437..e6b2cdf 100644
--- a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentColumn.java
+++ b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentColumn.java
@@ -56,26 +56,26 @@ CssBoxModel getBoxModel() {
var parsedWidth = CssUnitParser.parse(containerWidth);
if (parsedWidth.isPercent()) {
- parsedWidth = parsedWidth.withValue((sectionWidth * parsedWidth.value / 100));
+ parsedWidth = parsedWidth.withValue((sectionWidth * parsedWidth.value() / 100));
}
- containerWidth = doubleToString(parsedWidth.valueFullPrecision) + "px";
- childContainerWidth = doubleToString(parsedWidth.value - allPaddings) + "px";
+ containerWidth = doubleToString(parsedWidth.valueFullPrecision()) + "px";
+ childContainerWidth = doubleToString(parsedWidth.value() - allPaddings) + "px";
var columnWidth = CssUnitParser.parse(childContainerWidth);
return new CssBoxModel(
- parsedWidth.value,
+ parsedWidth.value(),
borders,
paddings,
- columnWidth.value
+ columnWidth.value()
);
}
var parsedContainerWidth = CssUnitParser.parse(context.containerWidth);
return new CssBoxModel(
- parsedContainerWidth.value,
+ parsedContainerWidth.value(),
borders,
paddings,
- parsedContainerWidth.value);
+ parsedContainerWidth.value());
}
private CssUnitParser.CssParsedUnit getParsedWidth() {
@@ -105,7 +105,7 @@ private String getMobileWidth() {
if (parsedWidth.isPercent()) {
return width;
} else {
- return doubleToString(parsedWidth.value / CssUnitParser.parse(containerWidth).value) + "%";
+ return doubleToString(parsedWidth.value() / CssUnitParser.parse(containerWidth).value()) + "%";
}
}
@@ -122,7 +122,7 @@ String getWidthAsPixel() {
private String getColumnClass() {
var parsedWidth = getParsedWidth();
- var formattedClassNb = doubleToString(parsedWidth.value).replace('.', '-');
+ var formattedClassNb = doubleToString(parsedWidth.value()).replace('.', '-');
var className = "mj-column-px-" + formattedClassNb;
diff --git a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentDivider.java b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentDivider.java
index 2f918d8..c4fb392 100644
--- a/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentDivider.java
+++ b/src/main/java/ch/digitalfondue/mjml4j/MjmlComponentDivider.java
@@ -44,26 +44,33 @@ private String getOutlookWidth() {
var parsedWidth = CssUnitParser.parse(getAttribute("width"));
- switch (parsedWidth.unit.toLowerCase(Locale.ROOT)) {
+ switch (parsedWidth.unit().toLowerCase(Locale.ROOT)) {
case "%": {
- var effectiveWidth = containerWidth.value - paddingSize;
- var percentMultiplier = parsedWidth.value / 100;
+ var effectiveWidth = containerWidth.value() - paddingSize;
+ var percentMultiplier = parsedWidth.value() / 100;
return doubleToString(effectiveWidth * percentMultiplier) + "px";
}
case "px":
return parsedWidth.toString();
default:
- return doubleToString(containerWidth.value - paddingSize) + "px";
+ return doubleToString(containerWidth.value() - paddingSize) + "px";
}
}
@Override
void setupStyles(CssStyleLibraries cssStyleLibraries) {
+ var computeAlign = "0px auto";
+ var alignAttribute = getAttribute("align");
+ if ("left".equals(alignAttribute)) {
+ computeAlign = "0px";
+ } else if ("right".equals(alignAttribute)) {
+ computeAlign = "0px 0px 0px auto";
+ }
var pStyle = mapOf(
"border-top", getAttribute("border-style") + " " + getAttribute("border-width") + " " + getAttribute("border-color"),
"font-size", "1px",
- "margin", "0px auto",
+ "margin", computeAlign,
"width", getAttribute("width")
);
@@ -80,7 +87,7 @@ private StringBuilder renderAfter(HtmlRenderer renderer) {
renderer.appendCurrentSpacing(res);
res.append("