├── .credo.exs ├── .doctor.exs ├── .formatter.exs ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── config └── config.exs ├── coveralls.json ├── guides └── images │ ├── logo.svg │ └── your_logo_here.png ├── lib ├── compiler.ex ├── compilers │ ├── node.ex │ └── rust.ex ├── engines │ └── mjml.ex ├── mjml_eex.ex ├── mjml_eex │ ├── component.ex │ └── layout.ex ├── telemetry.ex └── utils.ex ├── mix.exs ├── mix.lock └── test ├── mjml_eex_test.exs ├── node_compiler_test.exs ├── test_components ├── attribute_block.ex ├── dynamic_component.ex ├── head_block.ex └── invalid_dynamic_component.ex ├── test_helper.exs ├── test_layouts ├── assigns_layout.mjml.eex ├── base_layout.mjml.eex ├── invalid_layout.mjml.eex └── other_invalid_layout.mjml.eex └── test_templates ├── bad_expression_dynamic_component_template.mjml.eex ├── basic_template.mjml.eex ├── component_template.mjml.eex ├── conditional_template.mjml.eex ├── dynamic_component_template.mjml.eex ├── function_template.mjml.eex ├── gettext_template.mjml.eex ├── invalid_component_template.mjml.eex ├── invalid_dynamic_component_template.mjml.eex ├── invalid_template.mjml.eex └── layout_template.mjml.eex /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 86 | # You can also customize the exit_status of each check. 87 | # If you don't want TODO comments to cause `mix credo` to fail, just 88 | # set this value to 0 (zero). 89 | # 90 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 91 | {Credo.Check.Design.TagFIXME, []}, 92 | 93 | # 94 | ## Readability Checks 95 | # 96 | {Credo.Check.Readability.AliasOrder, []}, 97 | {Credo.Check.Readability.FunctionNames, []}, 98 | {Credo.Check.Readability.LargeNumbers, []}, 99 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 100 | {Credo.Check.Readability.ModuleAttributeNames, []}, 101 | {Credo.Check.Readability.ModuleDoc, []}, 102 | {Credo.Check.Readability.ModuleNames, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 105 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 106 | {Credo.Check.Readability.PredicateFunctionNames, []}, 107 | {Credo.Check.Readability.PreferImplicitTry, []}, 108 | {Credo.Check.Readability.RedundantBlankLines, []}, 109 | {Credo.Check.Readability.Semicolons, []}, 110 | {Credo.Check.Readability.SpaceAfterCommas, []}, 111 | {Credo.Check.Readability.StringSigils, []}, 112 | {Credo.Check.Readability.TrailingBlankLine, []}, 113 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 114 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 115 | {Credo.Check.Readability.VariableNames, []}, 116 | {Credo.Check.Readability.WithSingleClause, []}, 117 | 118 | # 119 | ## Refactoring Opportunities 120 | # 121 | # {Credo.Check.Refactor.Apply, []}, 122 | {Credo.Check.Refactor.CondStatements, []}, 123 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 12]}, 124 | {Credo.Check.Refactor.FunctionArity, []}, 125 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 126 | {Credo.Check.Refactor.MatchInCondition, []}, 127 | {Credo.Check.Refactor.MapJoin, []}, 128 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 129 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 130 | {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, 131 | {Credo.Check.Refactor.UnlessWithElse, []}, 132 | {Credo.Check.Refactor.WithClauses, []}, 133 | {Credo.Check.Refactor.FilterFilter, []}, 134 | {Credo.Check.Refactor.RejectReject, []}, 135 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 136 | 137 | # 138 | ## Warnings 139 | # 140 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 141 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 142 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 143 | {Credo.Check.Warning.IExPry, []}, 144 | {Credo.Check.Warning.IoInspect, []}, 145 | {Credo.Check.Warning.OperationOnSameValues, []}, 146 | {Credo.Check.Warning.OperationWithConstantResult, []}, 147 | {Credo.Check.Warning.RaiseInsideRescue, []}, 148 | {Credo.Check.Warning.SpecWithStruct, []}, 149 | {Credo.Check.Warning.WrongTestFileExtension, []}, 150 | {Credo.Check.Warning.UnusedEnumOperation, []}, 151 | {Credo.Check.Warning.UnusedFileOperation, []}, 152 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 153 | {Credo.Check.Warning.UnusedListOperation, []}, 154 | {Credo.Check.Warning.UnusedPathOperation, []}, 155 | {Credo.Check.Warning.UnusedRegexOperation, []}, 156 | {Credo.Check.Warning.UnusedStringOperation, []}, 157 | {Credo.Check.Warning.UnusedTupleOperation, []}, 158 | {Credo.Check.Warning.UnsafeExec, []} 159 | ], 160 | disabled: [ 161 | # 162 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 163 | 164 | # 165 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 166 | # and be sure to use `mix credo --strict` to see low priority checks) 167 | # 168 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 169 | {Credo.Check.Consistency.UnusedVariableNames, []}, 170 | {Credo.Check.Design.DuplicatedCode, []}, 171 | {Credo.Check.Design.SkipTestWithoutComment, []}, 172 | {Credo.Check.Readability.AliasAs, []}, 173 | {Credo.Check.Readability.BlockPipe, []}, 174 | {Credo.Check.Readability.ImplTrue, []}, 175 | {Credo.Check.Readability.MultiAlias, []}, 176 | {Credo.Check.Readability.NestedFunctionCalls, []}, 177 | {Credo.Check.Readability.SeparateAliasRequire, []}, 178 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 179 | {Credo.Check.Readability.SinglePipe, []}, 180 | {Credo.Check.Readability.Specs, []}, 181 | {Credo.Check.Readability.StrictModuleLayout, []}, 182 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 183 | {Credo.Check.Refactor.ABCSize, []}, 184 | {Credo.Check.Refactor.AppendSingleItem, []}, 185 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 186 | {Credo.Check.Refactor.FilterReject, []}, 187 | {Credo.Check.Refactor.IoPuts, []}, 188 | {Credo.Check.Refactor.MapMap, []}, 189 | {Credo.Check.Refactor.ModuleDependencies, []}, 190 | {Credo.Check.Refactor.NegatedIsNil, []}, 191 | {Credo.Check.Refactor.PipeChainStart, []}, 192 | {Credo.Check.Refactor.RejectFilter, []}, 193 | {Credo.Check.Refactor.VariableRebinding, []}, 194 | {Credo.Check.Warning.LazyLogging, []}, 195 | {Credo.Check.Warning.LeakyEnvironment, []}, 196 | {Credo.Check.Warning.MapGetUnsafePass, []}, 197 | {Credo.Check.Warning.MixEnv, []}, 198 | {Credo.Check.Warning.UnsafeToAtom, []} 199 | 200 | # {Credo.Check.Refactor.MapInto, []}, 201 | 202 | # 203 | # Custom checks can be created using `mix credo.gen.check`. 204 | # 205 | ] 206 | } 207 | } 208 | ] 209 | } 210 | -------------------------------------------------------------------------------- /.doctor.exs: -------------------------------------------------------------------------------- 1 | %Doctor.Config{ 2 | exception_moduledoc_required: true, 3 | failed: false, 4 | ignore_modules: [MjmlEEx, MjmlEEx.Layout], 5 | ignore_paths: [], 6 | min_module_doc_coverage: 40, 7 | min_module_spec_coverage: 0, 8 | min_overall_doc_coverage: 50, 9 | min_overall_spec_coverage: 0, 10 | moduledoc_required: true, 11 | raise: false, 12 | reporter: Doctor.Reporters.Full, 13 | struct_type_spec_required: true, 14 | umbrella: false 15 | } 16 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | line_length: 120, 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [akoutmos] 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: MJML EEx CI 2 | 3 | env: 4 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 5 | SHELL: sh 6 | 7 | on: 8 | push: 9 | branches: [master] 10 | pull_request: 11 | branches: [master] 12 | 13 | jobs: 14 | static_analysis: 15 | name: Static Analysis 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | - name: Set up Elixir 22 | uses: erlef/setup-beam@v1 23 | with: 24 | elixir-version: "1.16.3" 25 | otp-version: "26.2" 26 | - name: Restore dependencies cache 27 | uses: actions/cache@v2 28 | with: 29 | path: deps 30 | key: ${{ runner.os }}-mix-v2-${{ hashFiles('**/mix.lock') }} 31 | restore-keys: ${{ runner.os }}-mix-v2- 32 | - name: Install dependencies 33 | run: mix deps.get 34 | - name: Restore PLT cache 35 | uses: actions/cache@v2 36 | with: 37 | path: priv/plts 38 | key: ${{ runner.os }}-mix-v2-${{ hashFiles('**/mix.lock') }} 39 | restore-keys: ${{ runner.os }}-mix-v2- 40 | - name: Mix Formatter 41 | run: mix format --check-formatted 42 | - name: Check for compiler warnings 43 | run: mix compile --warnings-as-errors 44 | - name: Credo strict checks 45 | run: mix credo --strict 46 | - name: Doctor documentation checks 47 | run: mix doctor 48 | 49 | unit_test: 50 | name: Run ExUnit tests 51 | runs-on: ubuntu-latest 52 | 53 | strategy: 54 | matrix: 55 | version: 56 | - otp: 26.2 57 | elixir: 1.15.8 58 | - otp: 26.2 59 | elixir: 1.16.3 60 | - otp: 27.0 61 | elixir: 1.17.0-rc.1 62 | 63 | steps: 64 | - name: Checkout code 65 | uses: actions/checkout@v4 66 | - name: Set up Elixir 67 | uses: erlef/setup-beam@v1 68 | with: 69 | elixir-version: ${{ matrix.version.elixir }} 70 | otp-version: ${{ matrix.version.otp }} 71 | - name: Set up Node 72 | uses: actions/setup-node@v3 73 | with: 74 | node-version: 18 75 | - name: Restore dependencies cache 76 | uses: actions/cache@v2 77 | with: 78 | path: deps 79 | key: ${{ runner.os }}-mix-v2-${{ hashFiles('**/mix.lock') }} 80 | restore-keys: ${{ runner.os }}-mix-v2- 81 | - name: Install dependencies 82 | run: mix deps.get 83 | - name: Install Node MJML compiler 84 | run: npm install -g mjml 85 | - name: Set MJML path env var 86 | run: echo "$(npm bin)" >> $GITHUB_PATH 87 | - name: ExUnit tests 88 | env: 89 | MIX_ENV: test 90 | SHELL: /bin/bash 91 | run: mix coveralls.github 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | mjml_eex-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.2-otp-27 2 | erlang 27.0 3 | rust 1.79.0 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.12.0] - 2024-07-12 11 | 12 | - Upgraded to MJML 4.0.0 13 | 14 | ## [0.11.0] - 2024-06-20 15 | 16 | ### Changed 17 | 18 | - Upgraded dependencies 19 | - Removed forked Elixir Tokenizier 20 | 21 | ## [0.10.0] - 2024-02-19 22 | 23 | ### Changed 24 | 25 | - Upgrades the dependencies to the latest versions. 26 | 27 | ## [0.9.1] - 2022-02-10 28 | 29 | ### Changed 30 | 31 | - Relax `phoenix_html` to make it compatible with phoenix 1.7. 32 | - Upgrades the dependencies to the latest version. 33 | 34 | ## [0.9.0] - 2022-09-05 35 | 36 | ### Added 37 | 38 | - MJML EEx now has Telemetry support for the rendering process. Take a look at the 39 | `MjmlEEx.Telemetry` module for more details. 40 | 41 | ### Changed 42 | 43 | - The configuration options that are passed to MJML EEx have change in structure 44 | and there is now a `:compiler_opts` entry for options that are passed to the 45 | configured compiler. 46 | 47 | ## [0.8.1] - 2022-07-22 48 | 49 | ### Fixed 50 | 51 | - Removed `:erlexec` as an `:extra_application` so it does not cause compilation errors. 52 | 53 | ## [0.8.0] - 2022-07-22 54 | 55 | ### Changed 56 | 57 | - `:erlexec` is now an optional dependency. If you attempt to use the Node compiler without this dependency 58 | an error will be raised. The error message contains information on pulling it down and starting the `:erlexec` 59 | application. 60 | 61 | ## [0.7.0] - 2022-05-26 62 | 63 | ### Added 64 | 65 | - You can now chose your MJML compiler. By default the Rust NIF compiler is used, but there is also an 66 | adapter for the Node MJML compiler. 67 | 68 | ## [0.6.0] - 2022-05-06 69 | 70 | ### Added 71 | 72 | - The `render_static_component` function can be used to render components that don't make use of any assigns. For 73 | example, in your template you would have: `<%= render_static_component MyCoolComponent, static: "data" %>` and this 74 | can be rendered at compile time as well as runtime. 75 | - The `render_dynamic_component` function can be used to render components that make use of assigns at runtime. For 76 | example, in your template you would have: `<%= render_dynamic_component MyCoolComponent, static: @data %>`. 77 | 78 | ### Changed 79 | 80 | - When calling `use MjmlEEx`, if the `:mjml_template` option is not provided, the module attempts to find a template 81 | file in the same directory that has the same file name as the module (with the `.mjml.eex` extension instead 82 | of `.ex`). This functions similar to how Phoenix and LiveView handle their templates. 83 | 84 | ### Removed 85 | 86 | - `render_component` is no longer available and users should now use `render_static_component` or 87 | `render_dynamic_component`. 88 | 89 | ## [0.5.0] - 2022-04-28 90 | 91 | ### Added 92 | 93 | - Templates can now either be compiled at runtime or at compile time based on the options passed to `use MjmlEEx` 94 | 95 | ## [0.4.0] - 2022-04-27 96 | 97 | ### Fixed 98 | 99 | - Calls to `render_component` now evaluate the AST aliases in the context of the `__CALLER__` 100 | - EEx templates, components and layouts are tokenized prior to going through the MJML EEx engine as not to escape MJML content 101 | 102 | ## [0.3.0] - 2022-04-17 103 | 104 | ### Added 105 | 106 | - Ability to inject a template into a layout 107 | 108 | ## [0.2.0] - 2022-04-15 109 | 110 | ### Added 111 | 112 | - Ability to render MJML component partials in MJML templates via `render_component` 113 | - Macros for MJML templates 114 | - Custom EEx engine to compile MJML EEx template to HTML 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Koutmos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | MJML EEx Logo 3 |

4 | 5 |

6 | Easily create beautiful emails using MJML right from Elixir! 7 |

8 | 9 |

10 | 11 | Hex.pm 12 | 13 | 14 | 15 | GitHub Workflow Status (master) 17 | 18 | 19 | 20 | Coveralls master branch 21 | 22 | 23 | 24 | Support the project 25 | 26 |

27 | 28 |
29 | 30 | # Contents 31 | 32 | - [Installation](#installation) 33 | - [Supporting MJML EEx](#supporting-mjml_eex) 34 | - [Using MJML EEx](#setting-up-mjml_eex) 35 | - [Configuration](#configuration) 36 | - [Attribution](#attribution) 37 | 38 | ## Installation 39 | 40 | [Available in Hex](https://hex.pm/packages/mjml_eex), the package can be installed by adding `mjml_eex` to your list of 41 | dependencies in `mix.exs`: 42 | 43 | ```elixir 44 | def deps do 45 | [ 46 | {:mjml_eex, "~> 0.12.0"} 47 | ] 48 | end 49 | ``` 50 | 51 | Documentation can be found at [https://hexdocs.pm/mjml_eex](https://hexdocs.pm/mjml_eex). 52 | 53 | ## Supporting MJML EEx 54 | 55 | If you rely on this library to generate awesome looking emails for your application, it would much appreciated 56 | if you can give back to the project in order to help ensure its continued development. 57 | 58 | Checkout my [GitHub Sponsorship page](https://github.com/sponsors/akoutmos) if you want to help out! 59 | 60 | ### Gold Sponsors 61 | 62 | 63 | Support the project 64 | 65 | 66 | ### Silver Sponsors 67 | 68 | 69 | Support the project 70 | 71 | 72 | ### Bronze Sponsors 73 | 74 | 75 | Support the project 76 | 77 | 78 | ## Using MJML EEx 79 | 80 | ### Basic Usage 81 | 82 | Add `{:mjml_eex, "~> 0.7.0"}` to your `mix.exs` file and run `mix deps.get`. After you have that in place, you 83 | can go ahead and create a template module like so: 84 | 85 | ```elixir 86 | defmodule BasicTemplate do 87 | use MjmlEEx, mjml_template: "basic_template.mjml.eex" 88 | end 89 | ``` 90 | 91 | And the accompanying MJML EEx template `basic_template.mjml.eex` (note that the path is relative to the calling 92 | module path): 93 | 94 | ```html 95 | 96 | 97 | 98 | 99 | 100 | Hello <%= @first_name %> <%= @last_name %>! 101 | 102 | 103 | 104 | 105 | ``` 106 | 107 | With those two in place, you can now run `BasicTemplate.render(first_name: "Alex", last_name: "Koutmos")` and you 108 | will get back an HTML document that can be emailed to users. 109 | 110 | ### Using Functions from Template Module 111 | 112 | You can also call functions from your template module if they exist in your MJML EEx template using 113 | the following module declaration: 114 | 115 | ```elixir 116 | defmodule FunctionTemplate do 117 | use MjmlEEx, mjml_template: "function_template.mjml.eex" 118 | 119 | defp generate_full_name(first_name, last_name) do 120 | "#{first_name} #{last_name}" 121 | end 122 | end 123 | ``` 124 | 125 | In conjunction with the following template: 126 | 127 | ```html 128 | 129 | 130 | 131 | 132 | 133 | Hello <%= generate_full_name(@first_name, @last_name) %>! 134 | 135 | 136 | 137 | 138 | ``` 139 | 140 | In order to render the email you would then call: `FunctionTemplate.render(first_name: "Alex", last_name: "Koutmos")` 141 | 142 | ### Using Components 143 | 144 | **Static components** 145 | 146 | In addition to compiling single MJML EEx templates, you can also create MJML partials and include them 147 | in other MJML templates AND components using the special `render_static_component` function. With the following 148 | modules: 149 | 150 | ```elixir 151 | defmodule FunctionTemplate do 152 | use MjmlEEx, mjml_template: "component_template.mjml.eex" 153 | end 154 | ``` 155 | 156 | ```elixir 157 | defmodule HeadBlock do 158 | use MjmlEEx.Component 159 | 160 | @impl true 161 | def render(_opts) do 162 | """ 163 | 164 | Hello world! 165 | 166 | 167 | """ 168 | end 169 | end 170 | ``` 171 | 172 | And the following template: 173 | 174 | ```html 175 | 176 | <%= render_static_component HeadBlock %> 177 | 178 | 179 | 180 | 181 | 182 | Hello <%= generate_full_name(@first_name, @last_name) %>! 183 | 184 | 185 | 186 | 187 | ``` 188 | 189 | Be sure to look at the `MjmlEEx.Component` module for additional usage information as you can also pass options to your 190 | template and use them when generating the partial string. One thing to note is that when using 191 | `render_static_component`, the data that is passed to the component must be defined at compile time. This means that you 192 | cannot use any assigns that would be evaluated at runtime. For example, this would raise an error: 193 | 194 | ```elixir 195 | 196 | <%= render_static_component MyTextComponent, some_data: @some_data %> 197 | 198 | ``` 199 | 200 | **Dynamic components** 201 | 202 | If you need to render your components dynamically, use `render_dynamic_component` instead and be sure to configure your 203 | template module like below to generate the email HTML at runtime. First, you create your component, for example, `MyTemplate.CtaComponent.ex`: 204 | 205 | ```elixir 206 | def MyTemplate.CtaComponent do 207 | use MjmlEEx.Component, mode: :runtime 208 | 209 | @impl MjmlEEx.Component 210 | def render(assigns) do 211 | """ 212 | 213 | #{assigns[:call_to_action_text]} 214 | #{assigns[:call_to_action_link]} 215 | 216 | """ 217 | end 218 | end 219 | ``` 220 | 221 | then, in your MJML template, insert it using the `render_dynamic_template_component` function: 222 | 223 | ```html 224 | 225 | 226 | 227 | 228 | 229 | <%= render_dynamic_component MyTemplate.CtaComponent %{call_to_action_text: "Call to action text", 230 | call_to_action_link: "#{@cta_link}"} %> 231 | 232 | 233 | 234 | 235 | ``` 236 | 237 | In your `UserNotifier` module, or equivalent, you render your template, passing any assigns/data it expects: 238 | 239 | ```Elixir 240 | WelcomeEmail.render(call_to_action_text: call_to_action_text, call_to_action_link: call_to_action_link) 241 | ``` 242 | 243 | ### Using Layouts 244 | 245 | Often times, you'll want to create an Email skeleton or layout using MJML, and then inject your template into that 246 | layout. MJML EEx supports this functionality which makes it really easy to have business branded emails application 247 | wide without having to copy and paste the same boilerplate in every template. 248 | 249 | To create a layout, define a layout module like so: 250 | 251 | ```elixir 252 | defmodule BaseLayout do 253 | use MjmlEEx.Layout, mjml_layout: "base_layout.mjml.eex" 254 | end 255 | ``` 256 | 257 | And an accompanying layout like so: 258 | 259 | ```html 260 | 261 | 262 | Say hello to card 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | <%= @inner_content %> 272 | 273 | ``` 274 | 275 | As you can see, you can include assigns in your layout template (like `@padding`), but you also need to 276 | include a mandatory `@inner_content` expression. That way, MJML EEx knowns where to inject your template 277 | into the layout. With that in place, you just need to tell your template module what layout to use (if 278 | you are using a layout that is): 279 | 280 | ```elixir 281 | defmodule MyTemplate do 282 | use MjmlEEx, 283 | mjml_template: "my_template.mjml.eex", 284 | layout: BaseLayout 285 | end 286 | ``` 287 | 288 | And your template file can contain merely the parts that you need for that particular template: 289 | 290 | ```html 291 | ... 292 | ``` 293 | 294 | ## Using with Gettext 295 | 296 | Similarly to Phoenix live/dead views, you can leverage Gettext to produce translated emails. To use Gettext, you will 297 | need to have a Gettext module defined in your project (this should be created automatically for you when you create your 298 | Phoenix project via `mix phx.new MyApp`). Then your MjmlEEx module will look something like this: 299 | 300 | ```elixir 301 | defmodule MyApp.GettextTemplate do 302 | import MyApp.Gettext 303 | 304 | use MjmlEEx, 305 | mjml_template: "gettext_template.mjml.eex", 306 | mode: :compile 307 | end 308 | ``` 309 | 310 | Make sure that you have the `import MyApp.Gettext` statement before the `use MjmlEEx` statement as you will get a 311 | compiler error that the `gettext` function that is being called in the `gettext_template.mjml.eex` has not been defined. 312 | 313 | ## Configuration 314 | 315 | MJML EEx has support for both the 1st party [NodeJS compiler](https://github.com/mjmlio/mjml) and the 3rd party 316 | [Rust compiler](https://github.com/jdrouet/mrml). By default, MJML EEx uses the Rust compiler as there is an 317 | Elixir NIF built with [Rustler](https://github.com/rusterlium/rustler) that packages the Rust 318 | library for easy use: [mjml_nif](https://github.com/adoptoposs/mjml_nif). By default the Rust compiler is used 319 | as it does not require you to have NodeJS available. 320 | 321 | In order to use the NodeJS compiler, you can provide the following configuration in your `config.exs` file: 322 | 323 | ```elixir 324 | config :mjml_eex, compiler: MjmlEEx.Compilers.Node 325 | ``` 326 | 327 | Be sure to check out the documentation for the `MjmlEEx.Compilers.Node` module as it also requires some 328 | additional set up. 329 | 330 | ## Attribution 331 | 332 | - The logo for the project is an edited version of an SVG image from the [unDraw project](https://undraw.co/) 333 | - The Elixir MJML library that this library builds on top of [MJML](https://github.com/adoptoposs/mjml_nif) 334 | - The Rust MRML library that provides the MJML compilation functionality [MRML](https://github.com/jdrouet/mrml) 335 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.12.0 2 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() != :prod do 4 | config :git_hooks, 5 | auto_install: true, 6 | verbose: true, 7 | hooks: [ 8 | pre_commit: [ 9 | tasks: [ 10 | {:cmd, "mix format --check-formatted"}, 11 | {:cmd, "mix compile --warnings-as-errors"}, 12 | {:cmd, "mix credo --strict"}, 13 | {:cmd, "mix doctor"}, 14 | {:cmd, "mix test"} 15 | ] 16 | ] 17 | ] 18 | end 19 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /guides/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 39 | 43 | 47 | 51 | 55 | 59 | 63 | 67 | 71 | 75 | 79 | 83 | 87 | 91 | 95 | 99 | 104 | 108 | 112 | 116 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /guides/images/your_logo_here.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/mjml_eex/683aa301bdfb78278f596d435002737c26fa04c6/guides/images/your_logo_here.png -------------------------------------------------------------------------------- /lib/compiler.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.Compiler do 2 | @moduledoc """ 3 | This module defines the behaviour that all compiler implementations 4 | need to adhere to. 5 | """ 6 | 7 | @callback compile(mjml_template :: String.t()) :: {:ok, String.t()} | {:error, String.t()} 8 | end 9 | -------------------------------------------------------------------------------- /lib/compilers/node.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:exec) do 2 | defmodule MjmlEEx.Compilers.Node do 3 | @moduledoc """ 4 | This module implements the `MjmlEEx.Compiler` behaviour 5 | and allows you to compile your MJML templates using the Node 6 | CLI tool. This compiler expects you to have the `mjml` Node 7 | script accessible from the running environment. 8 | 9 | For information regarding the Node mjml compiler see: 10 | https://documentation.mjml.io/#command-line-interface 11 | 12 | ## Configuration 13 | 14 | In order to use this compiler, you need to set your application 15 | configration like so (in your `config.exs` file for example): 16 | 17 | ```elixir 18 | config :mjml_eex, 19 | compiler: MjmlEEx.Compilers.Node, 20 | compiler_opts: [ 21 | timeout: 10_000, 22 | path: "mjml" 23 | ] 24 | ``` 25 | 26 | In addition, since the Node compiler is run via `:erlexec`, you will 27 | need to add this optional dependency to your `mix.exs` file and also 28 | start the optional application: 29 | 30 | ```elixir 31 | def application do 32 | [ 33 | extra_applications: [..., :erlexec] 34 | ] 35 | end 36 | 37 | defp deps do 38 | [ 39 | ... 40 | {:erlexec, "~> 2.0"} 41 | ] 42 | end 43 | ``` 44 | """ 45 | 46 | @behaviour MjmlEEx.Compiler 47 | 48 | @impl true 49 | def compile(mjml_template) do 50 | # Get the configs for the compiler 51 | compiler_opts = Application.get_env(:mjml_eex, :compiler_opts) 52 | timeout = Keyword.get(compiler_opts, :timeout, 10_000) 53 | compiler_path = Keyword.get(compiler_opts, :path, "mjml") 54 | 55 | # Start the erlexec port 56 | {:ok, pid, os_pid} = 57 | :exec.run("#{compiler_path} -s -i --noStdoutFileComment", [:stdin, :stdout, :stderr, :monitor]) 58 | 59 | # Send the MJML template to the compiler via STDIN 60 | :exec.send(pid, mjml_template) 61 | :exec.send(pid, :eof) 62 | 63 | # Initial state for reduce 64 | initial_reduce_results = %{ 65 | stdout: "", 66 | stderr: [] 67 | } 68 | 69 | result = 70 | [nil] 71 | |> Stream.cycle() 72 | |> Enum.reduce_while(initial_reduce_results, fn _, acc -> 73 | receive do 74 | {:DOWN, ^os_pid, _, ^pid, {:exit_status, exit_status}} when exit_status != 0 -> 75 | error = "Node mjml CLI compiler exited with status code #{inspect(exit_status)}" 76 | existing_errors = Map.get(acc, :stderr, []) 77 | {:halt, Map.put(acc, :stderr, [error | existing_errors])} 78 | 79 | {:DOWN, ^os_pid, _, ^pid, _} -> 80 | {:halt, acc} 81 | 82 | {:stderr, ^os_pid, error} -> 83 | error = String.trim(error) 84 | existing_errors = Map.get(acc, :stderr, []) 85 | {:cont, Map.put(acc, :stderr, [error | existing_errors])} 86 | 87 | {:stdout, ^os_pid, compiled_template_fragment} -> 88 | aggregated_template = Map.get(acc, :stdout, "") 89 | {:cont, Map.put(acc, :stdout, aggregated_template <> compiled_template_fragment)} 90 | after 91 | timeout -> 92 | :exec.kill(os_pid, :sigterm) 93 | time_in_seconds = System.convert_time_unit(timeout, :millisecond, :second) 94 | error = "Node mjml CLI compiler timed out after #{time_in_seconds} second(s)" 95 | existing_errors = Map.get(acc, :stderr, []) 96 | {:halt, Map.put(acc, :stderr, [error | existing_errors])} 97 | end 98 | end) 99 | 100 | case result do 101 | %{stderr: [], stdout: compiled_template} -> 102 | {:ok, compiled_template} 103 | 104 | %{stderr: errors} -> 105 | {:error, Enum.join(errors, "\n")} 106 | end 107 | end 108 | end 109 | else 110 | defmodule MjmlEEx.Compilers.Node do 111 | @moduledoc false 112 | 113 | @behaviour MjmlEEx.Compiler 114 | 115 | @impl true 116 | def compile(_mjml_template) do 117 | raise(""" 118 | In order to use the Node compiler you must also update your mix.exs file like so: 119 | 120 | def application do 121 | [ 122 | extra_applications: [..., :erlexec] 123 | ] 124 | end 125 | 126 | defp deps do 127 | [ 128 | ... 129 | {:erlexec, "~> 2.0"} 130 | ] 131 | end 132 | """) 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/compilers/rust.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.Compilers.Rust do 2 | @moduledoc """ 3 | This module implements the `MjmlEEx.Compiler` behaviour 4 | and allows you to compile your MJML templates using the Rust 5 | NIF (https://hexdocs.pm/mjml/readme.html). 6 | 7 | This is the default compiler. 8 | """ 9 | 10 | @behaviour MjmlEEx.Compiler 11 | 12 | @impl true 13 | def compile(mjml_template) do 14 | Mjml.to_html(mjml_template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/engines/mjml.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.Engines.Mjml do 2 | @moduledoc """ 3 | This Engine is used to compile the MJML template. 4 | """ 5 | 6 | alias MjmlEEx.Utils 7 | 8 | @behaviour EEx.Engine 9 | 10 | @impl true 11 | def init(opts) do 12 | {caller, remaining_opts} = Keyword.pop!(opts, :caller) 13 | {mode, remaining_opts} = Keyword.pop!(remaining_opts, :mode) 14 | {rendering_dynamic_component, remaining_opts} = Keyword.pop(remaining_opts, :rendering_dynamic_component, false) 15 | 16 | remaining_opts 17 | |> EEx.Engine.init() 18 | |> Map.put(:caller, caller) 19 | |> Map.put(:mode, mode) 20 | |> Map.put(:rendering_dynamic_component, rendering_dynamic_component) 21 | end 22 | 23 | @impl true 24 | defdelegate handle_body(state), to: EEx.Engine 25 | 26 | @impl true 27 | defdelegate handle_begin(state), to: EEx.Engine 28 | 29 | @impl true 30 | defdelegate handle_end(state), to: EEx.Engine 31 | 32 | @impl true 33 | defdelegate handle_text(state, meta, text), to: EEx.Engine 34 | 35 | @impl true 36 | def handle_expr(%{mode: :compile}, _marker, {:render_dynamic_component, _, _}) do 37 | raise "render_dynamic_component can only be used with runtime generated templates. Switch your template to `mode: :runtime`" 38 | end 39 | 40 | def handle_expr(%{rendering_dynamic_component: true}, _marker, {:render_dynamic_component, _, _}) do 41 | raise "Cannot call `render_dynamic_component` inside of another dynamically rendered component" 42 | end 43 | 44 | def handle_expr(state, "=", {:render_dynamic_component, _, [{:__aliases__, _, _module} = aliases]}) do 45 | module = Macro.expand(aliases, state.caller) 46 | 47 | do_render_dynamic_component(state, module, []) 48 | end 49 | 50 | def handle_expr(state, "=", {:render_dynamic_component, _, [{:__aliases__, _, _module} = aliases, opts]}) do 51 | module = Macro.expand(aliases, state.caller) 52 | 53 | do_render_dynamic_component(state, module, opts) 54 | end 55 | 56 | def handle_expr(_state, _marker, {:render_dynamic_component, _, _}) do 57 | raise "render_dynamic_component can only be invoked inside of an <%= ... %> expression" 58 | end 59 | 60 | def handle_expr(state, "=", {:render_static_component, _, [{:__aliases__, _, _module} = aliases]}) do 61 | module = Macro.expand(aliases, state.caller) 62 | 63 | do_render_static_component(state, module, []) 64 | end 65 | 66 | def handle_expr(state, "=", {:render_static_component, _, [{:__aliases__, _, _module} = aliases, opts]}) do 67 | module = Macro.expand(aliases, state.caller) 68 | 69 | do_render_static_component(state, module, opts) 70 | end 71 | 72 | def handle_expr(_state, _marker, {:render_static_component, _, _}) do 73 | raise "render_static_component can only be invoked inside of an <%= ... %> expression" 74 | end 75 | 76 | def handle_expr(_state, marker, expr) do 77 | raise "Invalid expression. Components can only have `render_static_component` and `render_dynamic_component` EEx expression: <%#{marker} #{Macro.to_string(expr)} %>" 78 | end 79 | 80 | defp do_render_static_component(state, module, opts) do 81 | {mjml_component, _} = 82 | module 83 | |> apply(:render, [opts]) 84 | |> Utils.escape_eex_expressions() 85 | |> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: state.caller, mode: state.mode) 86 | |> Code.eval_quoted() 87 | 88 | %{binary: binary} = state 89 | %{state | binary: [mjml_component | binary]} 90 | end 91 | 92 | defp do_render_dynamic_component(state, module, opts) do 93 | caller = 94 | state 95 | |> Map.get(:caller) 96 | |> :erlang.term_to_binary() 97 | |> Base.encode64() 98 | 99 | mjml_component = 100 | "<%= Phoenix.HTML.raw(MjmlEEx.Utils.render_dynamic_component(#{module}, #{Macro.to_string(opts)}, \"#{caller}\")) %>" 101 | 102 | %{binary: binary} = state 103 | %{state | binary: [mjml_component | binary]} 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/mjml_eex.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx do 2 | @moduledoc """ 3 | Documentation for `MjmlEEx` template module. This moule contains the macro 4 | that is used to create an MJML EEx template. The macro can be configured to 5 | render the MJML template in a few different ways, so be sure to read the 6 | option documentation. 7 | 8 | ## Macro Options 9 | 10 | - `:mjml_template`- A binary that specifies the name of the `.mjml.eex` template that the module will compile. The 11 | directory path is relative to the template module. If this option is not provided, the MjmlEEx will look for a 12 | file that has the same name as the module but with the `.mjml.ex` extension as opposed to `.ex`. 13 | 14 | - `:mode`- This option defines when the MJML template is actually compiled. The possible values are `:runtime` and 15 | `:compile`. When this option is set to `:compile`, the MJML template is compiled into email compatible HTML at 16 | compile time. It is suggested that this mode is only used if the template is relatively simple and there are only 17 | assigns being used as text or attributes on html elements (as opposed to attributes on MJML elements). The reason 18 | for that being that these assigns may be discarded as part of the MJML compilation phase. On the plus side, you 19 | do get a performance bump here since the HTML for the email is already generated. When this is set to `:runtime`, 20 | the MJML template is compiled at runtime and all the template assigns are applied prior to the MJML compilation 21 | phase. These means that there is a performance hit since you are compiling the MJML template every time, but the 22 | template can use more complex EEx constructs like `for`, `case` and `cond`. The default configuration is `:runtime`. 23 | 24 | - `:layout` - This option defines what layout the template should be injected into prior to rendering the template. 25 | This is useful if you want to have reusable email templates in order to keep your email code DRY and reusable. 26 | Your template will then be injected into the layout where the layout defines `<%= inner_content %>`. 27 | 28 | ## Example Usage 29 | 30 | You can use this module like so: 31 | 32 | ```elixir 33 | defmodule BasicTemplate do 34 | use MjmlEEx, mjml_template: "basic_template.mjml.eex" 35 | end 36 | ``` 37 | 38 | Along with the `basic_template.mjml.eex MJML` template located in the same 39 | directory as the module containing the following: 40 | 41 | ```html 42 | 43 | 44 | 45 | 46 | 47 | Hello <%= @first_name %> <%= @last_name %>! 48 | 49 | 50 | 51 | 52 | ``` 53 | 54 | Once that is in place, you can render the final HTML document by running: 55 | 56 | ```elixir 57 | BasicTemplate.render(first_name: "Alex", last_name: "Koutmos") 58 | ``` 59 | """ 60 | 61 | alias MjmlEEx.Utils 62 | 63 | defmacro __using__(opts) do 64 | # Get some data about the calling module 65 | %Macro.Env{file: calling_module_file} = __CALLER__ 66 | module_directory = Path.dirname(calling_module_file) 67 | file_minus_extension = Path.basename(calling_module_file, ".ex") 68 | mjml_template_file = Keyword.get(opts, :mjml_template, "#{file_minus_extension}.mjml.eex") 69 | 70 | # The absolute path of the mjml template 71 | mjml_template = Path.join(module_directory, mjml_template_file) 72 | 73 | unless File.exists?(mjml_template) do 74 | raise "The provided :mjml_template does not exist at #{inspect(mjml_template)}." 75 | end 76 | 77 | # Get the options passed to the macro or set the defaults 78 | layout_module = opts |> Keyword.get(:layout, :none) |> Macro.expand(__CALLER__) 79 | compilation_mode = Keyword.get(opts, :mode, :runtime) 80 | 81 | unless layout_module == :none do 82 | Code.ensure_compiled!(layout_module) 83 | end 84 | 85 | raw_mjml_template = 86 | case layout_module do 87 | :none -> 88 | get_raw_template(mjml_template, compilation_mode, __CALLER__) 89 | 90 | module when is_atom(module) -> 91 | get_raw_template_with_layout(mjml_template, layout_module, compilation_mode, __CALLER__) 92 | end 93 | 94 | generate_functions(compilation_mode, raw_mjml_template, mjml_template, layout_module) 95 | end 96 | 97 | @doc """ 98 | Get the configured MJML compiler. By default, the `MjmlEEx.Compilers.Rust` compiler 99 | is used. 100 | """ 101 | def configured_compiler do 102 | Application.get_env(:mjml_eex, :compiler, MjmlEEx.Compilers.Rust) 103 | end 104 | 105 | defp generate_functions(:runtime, raw_mjml_template, mjml_template_file, layout_module) do 106 | phoenix_html_ast = EEx.compile_string(raw_mjml_template, engine: Phoenix.HTML.Engine, line: 1) 107 | 108 | quote do 109 | @external_resource unquote(mjml_template_file) 110 | 111 | if unquote(layout_module) != :none do 112 | @external_resource unquote(layout_module).__layout_file__() 113 | end 114 | 115 | @doc "Returns the raw MJML template. Useful for debugging rendering issues." 116 | def debug_mjml_template do 117 | unquote(raw_mjml_template) 118 | end 119 | 120 | @doc "Safely render the MJML template using Phoenix.HTML" 121 | def render(assigns) do 122 | compiler = MjmlEEx.configured_compiler() 123 | 124 | telemetry_metadata = %{ 125 | compiler: compiler, 126 | mode: :runtime, 127 | assigns: assigns, 128 | mjml_template: unquote(raw_mjml_template), 129 | mjml_template_file: unquote(mjml_template_file), 130 | layout_module: unquote(layout_module) 131 | } 132 | 133 | :telemetry.span( 134 | [:mjml_eex, :render], 135 | telemetry_metadata, 136 | fn -> 137 | assigns 138 | |> apply_assigns_to_template() 139 | |> Phoenix.HTML.safe_to_string() 140 | |> compiler.compile() 141 | |> case do 142 | {:ok, email_html} -> 143 | {email_html, Map.put(telemetry_metadata, :rendered_html, email_html)} 144 | 145 | {:error, error} -> 146 | raise "Failed to compile MJML template: #{inspect(error)}" 147 | end 148 | end 149 | ) 150 | end 151 | 152 | defp apply_assigns_to_template(var!(assigns)) do 153 | _ = var!(assigns) 154 | unquote(phoenix_html_ast) 155 | end 156 | end 157 | end 158 | 159 | defp generate_functions(:compile, raw_mjml_template, mjml_template_file, layout_module) do 160 | compiler = MjmlEEx.configured_compiler() 161 | 162 | phoenix_html_ast = 163 | raw_mjml_template 164 | |> Utils.escape_eex_expressions() 165 | |> compiler.compile() 166 | |> case do 167 | {:ok, email_html} -> 168 | email_html 169 | 170 | {:error, error} -> 171 | raise "Failed to compile MJML template: #{inspect(error)}" 172 | end 173 | |> Utils.decode_eex_expressions() 174 | |> EEx.compile_string(engine: Phoenix.HTML.Engine, line: 1) 175 | 176 | quote do 177 | @external_resource unquote(mjml_template_file) 178 | 179 | if unquote(layout_module) != :none do 180 | @external_resource unquote(layout_module).__layout_file__() 181 | end 182 | 183 | @doc "Returns the escaped MJML template. Useful for debugging rendering issues." 184 | def debug_mjml_template do 185 | unquote(raw_mjml_template) 186 | end 187 | 188 | @doc "Safely render the MJML template using Phoenix.HTML" 189 | def render(assigns) do 190 | telemetry_metadata = %{ 191 | compiler: unquote(compiler), 192 | mode: :compile, 193 | assigns: assigns, 194 | mjml_template: unquote(raw_mjml_template), 195 | mjml_template_file: unquote(mjml_template_file), 196 | layout_module: unquote(layout_module) 197 | } 198 | 199 | :telemetry.span( 200 | [:mjml_eex, :render], 201 | telemetry_metadata, 202 | fn -> 203 | email_html = 204 | assigns 205 | |> apply_assigns_to_template() 206 | |> Phoenix.HTML.safe_to_string() 207 | 208 | {email_html, Map.put(telemetry_metadata, :rendered_html, email_html)} 209 | end 210 | ) 211 | end 212 | 213 | defp apply_assigns_to_template(var!(assigns)) do 214 | _ = var!(assigns) 215 | unquote(phoenix_html_ast) 216 | end 217 | end 218 | end 219 | 220 | defp generate_functions(invalid_mode, _, _, _) do 221 | raise "#{inspect(invalid_mode)} is an invalid :mode. Possible values are :runtime or :compile" 222 | end 223 | 224 | defp get_raw_template(template_path, mode, caller) do 225 | {mjml_document, _} = 226 | template_path 227 | |> File.read!() 228 | |> Utils.escape_eex_expressions() 229 | |> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller, mode: mode) 230 | |> Code.eval_quoted() 231 | 232 | Utils.decode_eex_expressions(mjml_document) 233 | end 234 | 235 | defp get_raw_template_with_layout(template_path, layout_module, mode, caller) do 236 | template_file_contents = File.read!(template_path) 237 | pre_inner_content = layout_module.pre_inner_content() 238 | post_inner_content = layout_module.post_inner_content() 239 | 240 | {mjml_document, _} = 241 | [pre_inner_content, template_file_contents, post_inner_content] 242 | |> Enum.join() 243 | |> Utils.escape_eex_expressions() 244 | |> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller, mode: mode) 245 | |> Code.eval_quoted() 246 | 247 | Utils.decode_eex_expressions(mjml_document) 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /lib/mjml_eex/component.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.Component do 2 | @moduledoc """ 3 | This module allows you to define a reusable MJML component that can be injected into 4 | an MJML template prior to it being rendered into HTML. There are two different ways 5 | that components can be rendered in templates. The first being `render_static_component` 6 | and the other being `render_dynamic_component`. `render_static_component` should be used 7 | to render the component when the data provided to the component is known at compile time. 8 | If you want to dynamically render a component (make sure that the template is set to 9 | `mode: :runtime`) with assigns that are passed to the template, then use 10 | `render_dynamic_component`. 11 | 12 | ## Example Usage 13 | 14 | To use an MjmlEEx component, create an `MjmlEEx.Component` module that looks like so: 15 | 16 | ```elixir 17 | defmodule HeadBlock do 18 | use MjmlEEx.Component 19 | 20 | @impl true 21 | def render(_opts) do 22 | \""" 23 | 24 | Hello world! 25 | 26 | 27 | \""" 28 | end 29 | end 30 | ``` 31 | 32 | With that in place, anywhere that you would like to use the component, you can add: 33 | `<%= render_static_component HeadBlock %>` in your MJML EEx template. 34 | 35 | You can also pass options to the render function like so: 36 | 37 | ```elixir 38 | defmodule HeadBlock do 39 | use MjmlEEx.Component 40 | 41 | @impl true 42 | def render(opts) do 43 | \""" 44 | 45 | \#{opts[:title]} 46 | 47 | 48 | \""" 49 | end 50 | end 51 | ``` 52 | 53 | And calling it like so: `<%= render_static_component(HeadBlock, title: "Some really cool title") %>` 54 | """ 55 | 56 | @doc """ 57 | Returns the MJML markup for the component as a string. 58 | """ 59 | @callback render(opts :: keyword()) :: String.t() 60 | 61 | defmacro __using__(_opts) do 62 | quote do 63 | @behaviour MjmlEEx.Component 64 | 65 | @impl true 66 | def render(_opts) do 67 | raise "Your MjmlEEx component must implement a render/1 callback." 68 | end 69 | 70 | defoverridable MjmlEEx.Component 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/mjml_eex/layout.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.Layout do 2 | @moduledoc """ 3 | This module allows you to define an MJML layout so that you 4 | can create reusable email skeletons. To use layouts with your 5 | MJML emails, create a layout template that contains an 6 | `<%= @inner_content %>` expression in it like so: 7 | 8 | ```html 9 | 10 | 11 | Say hello to card 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <%= @inner_content %> 21 | 22 | ``` 23 | 24 | You can also include additional assigns like `@padding` in this 25 | example. Just make sure that you provide that assign when you 26 | are rendering the final template. With that in place, you can 27 | define a layout module like so 28 | 29 | ```elixir 30 | defmodule BaseLayout do 31 | use MjmlEEx.Layout, mjml_layout: "base_layout.mjml.eex" 32 | end 33 | ``` 34 | 35 | And then use it in conjunction with your templates like so: 36 | 37 | ```elixir 38 | defmodule MyTemplate do 39 | use MjmlEEx, 40 | mjml_template: "my_template.mjml.eex", 41 | layout: BaseLayout 42 | end 43 | ``` 44 | 45 | Then in your template, all you need to provide are the portions that 46 | you need to complete the layout: 47 | 48 | ```html 49 | 50 | ... 51 | 52 | ``` 53 | """ 54 | 55 | defmacro __using__(opts) do 56 | mjml_layout = 57 | case Keyword.fetch(opts, :mjml_layout) do 58 | {:ok, mjml_layout} -> 59 | %Macro.Env{file: calling_module_file} = __CALLER__ 60 | 61 | calling_module_file 62 | |> Path.dirname() 63 | |> Path.join(mjml_layout) 64 | 65 | :error -> 66 | raise "The :mjml_layout option is required." 67 | end 68 | 69 | # Ensure that the file exists 70 | unless File.exists?(mjml_layout) do 71 | raise "The provided :mjml_layout does not exist at #{inspect(mjml_layout)}." 72 | end 73 | 74 | # Extract the contents and ensure that it conforms to the 75 | # requirements for a layout 76 | layout_file_contents = File.read!(mjml_layout) 77 | 78 | # Extract the pre and post content sections 79 | [pre_inner_content, post_inner_content] = 80 | case Regex.split(~r/\<\%\=\s*\@inner_content\s*\%\>/, layout_file_contents) do 81 | [pre_inner_content, post_inner_content] -> 82 | [pre_inner_content, post_inner_content] 83 | 84 | [_layout_template] -> 85 | raise "The provided :mjml_layout must contain one <%= @inner_content %> expression." 86 | 87 | _ -> 88 | raise "The provided :mjml_layout contains multiple <%= @inner_content %> expressions." 89 | end 90 | 91 | quote do 92 | @external_resource unquote(mjml_layout) 93 | 94 | @doc false 95 | def pre_inner_content do 96 | unquote(pre_inner_content) 97 | end 98 | 99 | @doc false 100 | def post_inner_content do 101 | unquote(post_inner_content) 102 | end 103 | 104 | @doc false 105 | def __layout_file__ do 106 | unquote(mjml_layout) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.Telemetry do 2 | @moduledoc """ 3 | Telemetry integration for event metrics, logging and error reporting. 4 | 5 | ### Render events 6 | 7 | MJML EEx emits the following telemetry events whenever a template is rendered: 8 | 9 | * `[:mjml_eex, :render, :start]` - When the rendering process has begun 10 | * `[:mjml_eex, :render, :stop]` - When the rendering process has successfully completed 11 | * `[:mjml_eex, :render, :exception]` - When the rendering process resulted in an error 12 | 13 | The render events contain the following measurements and metadata: 14 | 15 | | event | measures | metadata | 16 | | ------------ | ---------------| ------------------------------------------------------------------------------------------------------------------------------ | 17 | | `:start` | `:system_time` | `:compiler`, `:mode`, `:assigns`, `:mjml_template`, `:mjml_template_file`, `:layout_module` | 18 | | `:stop` | `:duration` | `:compiler`, `:mode`, `:assigns`, `:mjml_template`, `:mjml_template_file`, `:layout_module` | 19 | | `:exception` | `:duration` | `:compiler`, `:mode`, `:assigns`, `:mjml_template`, `:mjml_template_file`, `:layout_module`, `:kind`, `:reason`, `:stacktrace` | 20 | """ 21 | 22 | require Logger 23 | 24 | @logger_event_id "mjml_eex_default_logger" 25 | 26 | @doc """ 27 | This function attaches a Telemetry debug handler to MJML EEx so that you can 28 | see what emails are being rendered, under what conditions, and what the 29 | resulting HTML looks like. This is primarily used for debugging purposes 30 | but can be modified for use in production if you need to. 31 | """ 32 | def attach_logger(opts \\ []) do 33 | events = [ 34 | [:mjml_eex, :render, :start], 35 | [:mjml_eex, :render, :stop], 36 | [:mjml_eex, :render, :exception] 37 | ] 38 | 39 | opts = Keyword.put_new(opts, :level, :debug) 40 | 41 | :telemetry.attach_many(@logger_event_id, events, &__MODULE__.handle_event/4, opts) 42 | end 43 | 44 | @doc """ 45 | Detach the debugging logger so that log messages are no longer produced. 46 | """ 47 | def detach_logger do 48 | :telemetry.detach(@logger_event_id) 49 | end 50 | 51 | @doc false 52 | def handle_event([:mjml_eex, :render, event], measurements, metadata, opts) do 53 | level = Keyword.fetch!(opts, :level) 54 | 55 | Logger.log(level, "Event: #{inspect(event)}") 56 | Logger.log(level, "Measurements: #{inspect(measurements)}") 57 | Logger.log(level, "Metadata: #{inspect(metadata, printable_limit: :infinity)}") 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.Utils do 2 | @moduledoc """ 3 | General MJML EEx utils reside here for encoding and decoding 4 | Elixir expressions in MJML EEx templates. 5 | """ 6 | 7 | @mjml_eex_special_expressions [:render_static_component, :render_dynamic_component] 8 | 9 | @doc """ 10 | This function encodes the internals of an MJML EEx document 11 | so that when it is compiled, the EEx expressions don't break 12 | the MJML compiler. 13 | """ 14 | def encode_expression(marker, expression) when is_binary(expression) do 15 | encoded_code = Base.encode16("<%#{marker} #{String.trim(expression)} %>") 16 | 17 | "__MJML_EEX_START__:#{encoded_code}:__MJML_EEX_END__" 18 | end 19 | 20 | def encode_expression(marker, expression) when is_list(expression) do 21 | encode_expression(marker, List.to_string(expression)) 22 | end 23 | 24 | @doc """ 25 | This function finds all of the instances of of encoded EEx expressions 26 | and decodes them so that when the EEx HTML template is finally 27 | rendered, the expressions are executed as expected. 28 | """ 29 | def decode_eex_expressions(email_document) do 30 | ~r/__MJML_EEX_START__:([^:]+):__MJML_EEX_END__/ 31 | |> Regex.replace(email_document, fn _, base16_code -> 32 | "#{decode_expression(base16_code)}" 33 | end) 34 | end 35 | 36 | defp decode_expression(encoded_string) do 37 | Base.decode16!(encoded_string) 38 | end 39 | 40 | @doc """ 41 | This function goes through and espaces all non-special EEx expressions 42 | so that they do not throw off the the MJML compiler. 43 | """ 44 | def escape_eex_expressions(template) do 45 | template 46 | |> EEx.Compiler.tokenize([]) 47 | |> case do 48 | {:ok, tokens} -> 49 | reduce_tokens(tokens) 50 | 51 | error -> 52 | raise "Failed to tokenize EEx template: #{inspect(error)}" 53 | end 54 | end 55 | 56 | @doc false 57 | def render_dynamic_component(module, opts, caller) do 58 | caller = 59 | caller 60 | |> Base.decode64!() 61 | |> :erlang.binary_to_term() 62 | 63 | {mjml_component, _} = 64 | module 65 | |> apply(:render, [opts]) 66 | |> EEx.compile_string( 67 | engine: MjmlEEx.Engines.Mjml, 68 | line: 1, 69 | trim: true, 70 | caller: caller, 71 | mode: :runtime, 72 | rendering_dynamic_component: true 73 | ) 74 | |> Code.eval_quoted() 75 | 76 | mjml_component 77 | end 78 | 79 | defp reduce_tokens(tokens) do 80 | tokens 81 | |> Enum.reduce("", fn 82 | {:text, content, _location}, acc -> 83 | additional_content = List.to_string(content) 84 | acc <> additional_content 85 | 86 | {token, marker, expression, _location}, acc when token in [:expr, :start_expr, :middle_expr, :end_expr] -> 87 | captured_expression = 88 | expression 89 | |> List.to_string() 90 | |> Code.string_to_quoted() 91 | 92 | case captured_expression do 93 | {:ok, {special_expression, _line, _args}} when special_expression in @mjml_eex_special_expressions -> 94 | acc <> "<%#{normalize_marker(marker)} #{List.to_string(expression)} %>" 95 | 96 | _ -> 97 | acc <> encode_expression(normalize_marker(marker), expression) 98 | end 99 | 100 | {:eof, _location}, acc -> 101 | acc 102 | end) 103 | end 104 | 105 | defp normalize_marker([]), do: "" 106 | defp normalize_marker(marker), do: List.to_string(marker) 107 | end 108 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :mjml_eex, 7 | version: project_version(), 8 | elixir: ">= 1.15.0", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | name: "MJML EEx", 11 | source_url: "https://github.com/akoutmos/mjml_eex", 12 | homepage_url: "https://hex.pm/packages/mjml_eex", 13 | description: "Create emails that WOW your customers using MJML and EEx", 14 | start_permanent: Mix.env() == :prod, 15 | test_coverage: [tool: ExCoveralls], 16 | preferred_cli_env: [ 17 | coveralls: :test, 18 | "coveralls.detail": :test, 19 | "coveralls.post": :test, 20 | "coveralls.html": :test, 21 | "coveralls.github": :test 22 | ], 23 | package: package(), 24 | deps: deps(), 25 | docs: docs(), 26 | aliases: aliases() 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application do 32 | [ 33 | extra_applications: [:logger] 34 | ] 35 | end 36 | 37 | # Specifies which paths to compile per environment. 38 | defp elixirc_paths(:test), do: ["lib", "test/test_components", "test/test_layouts"] 39 | defp elixirc_paths(_), do: ["lib"] 40 | 41 | defp package do 42 | [ 43 | name: "mjml_eex", 44 | files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md VERSION), 45 | licenses: ["MIT"], 46 | maintainers: ["Alex Koutmos"], 47 | links: %{ 48 | "GitHub" => "https://github.com/akoutmos/mjml_eex", 49 | "Sponsor" => "https://github.com/sponsors/akoutmos" 50 | } 51 | ] 52 | end 53 | 54 | defp docs do 55 | [ 56 | main: "readme", 57 | source_ref: "master", 58 | logo: "guides/images/logo.svg", 59 | extras: ["README.md"] 60 | ] 61 | end 62 | 63 | # Run "mix help deps" to learn about dependencies. 64 | defp deps do 65 | [ 66 | # Production deps 67 | {:mjml, "~> 4.0"}, 68 | {:phoenix_html, "~> 3.2 or ~> 4.0"}, 69 | {:telemetry, "~> 1.0"}, 70 | {:erlexec, "~> 2.0.7", optional: true}, 71 | 72 | # Development deps 73 | {:gettext, "~> 0.24.0", only: :test}, 74 | {:ex_doc, "~> 0.34", only: :dev}, 75 | {:excoveralls, "~> 0.18", only: [:test, :dev], runtime: false}, 76 | {:doctor, "~> 0.21", only: :dev}, 77 | {:credo, "~> 1.7", only: :dev}, 78 | {:git_hooks, "~> 0.7", only: [:test, :dev], runtime: false} 79 | ] 80 | end 81 | 82 | defp aliases do 83 | [ 84 | docs: ["docs", ©_files/1] 85 | ] 86 | end 87 | 88 | defp project_version do 89 | "VERSION" 90 | |> File.read!() 91 | |> String.trim() 92 | end 93 | 94 | defp copy_files(_) do 95 | # Set up directory structure 96 | File.mkdir_p!("./doc/guides/images") 97 | 98 | # Copy over image files 99 | "./guides/images/" 100 | |> File.ls!() 101 | |> Enum.each(fn image_file -> 102 | File.cp!("./guides/images/#{image_file}", "./doc/guides/images/#{image_file}") 103 | end) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 5 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 6 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 7 | "doctor": {:hex, :doctor, "0.21.0", "20ef89355c67778e206225fe74913e96141c4d001cb04efdeba1a2a9704f1ab5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "a227831daa79784eb24cdeedfa403c46a4cb7d0eab0e31232ec654314447e4e0"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 9 | "erlexec": {:hex, :erlexec, "2.0.7", "76d0bc7487929741b5bb9f74da2af5daf1492134733cf9a05c7aaa278b6934c5", [:rebar3], [], "hexpm", "af2dd940bb8e32f5aa40a65cb455dcaa18f5334fd3507e9bfd14a021e9630897"}, 10 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.1", "a6f547570c6b24ec13f122a5634833a063aec49218f6fff27de9df693a15588c", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d65f79db146bb20399f23046015974de0079668b9abb2f5aac074d078da60b8d"}, 12 | "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, 13 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 14 | "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, 15 | "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, 16 | "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, 17 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 20 | "mjml": {:hex, :mjml, "4.0.0", "3418c1a975112766f92b8786a17d53640a4a1c8c7dedb621016a08e1e1447d20", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "0886a411f9a850bc4ac6b878c9c0dbea168669176fde1f6583acf55fa8b628dd"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 22 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 23 | "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, 24 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.7.2", "097f657e401f02e7bc1cab808cfc6abdc1f7b9dc5e5adee46bf2fd8fdcce9ecf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "7663faaeadc9e93e605164dcf9e69168e35f2f8b7f2b9eb4e400d1a8e0fe2999"}, 25 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 26 | } 27 | -------------------------------------------------------------------------------- /test/mjml_eex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MjmlEExTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | 6 | alias MjmlEEx.Telemetry 7 | 8 | defmodule BasicTemplate do 9 | use MjmlEEx, 10 | mjml_template: "test_templates/basic_template.mjml.eex", 11 | mode: :compile 12 | end 13 | 14 | defmodule ConditionalTemplate do 15 | use MjmlEEx, 16 | mjml_template: "test_templates/conditional_template.mjml.eex", 17 | mode: :compile 18 | end 19 | 20 | defmodule ComponentTemplate do 21 | use MjmlEEx, 22 | mjml_template: "test_templates/component_template.mjml.eex", 23 | mode: :compile 24 | end 25 | 26 | defmodule DynamicComponentTemplate do 27 | use MjmlEEx, 28 | mjml_template: "test_templates/dynamic_component_template.mjml.eex", 29 | mode: :runtime 30 | end 31 | 32 | defmodule InvalidDynamicComponentTemplate do 33 | use MjmlEEx, 34 | mjml_template: "test_templates/invalid_dynamic_component_template.mjml.eex", 35 | mode: :runtime 36 | end 37 | 38 | defmodule FunctionTemplate do 39 | use MjmlEEx, 40 | mjml_template: "test_templates/function_template.mjml.eex", 41 | mode: :compile 42 | 43 | defp generate_full_name(first_name, last_name) do 44 | "#{first_name} #{last_name}" 45 | end 46 | end 47 | 48 | defmodule BaseLayout do 49 | @moduledoc false 50 | 51 | use MjmlEEx.Layout, 52 | mjml_layout: "test_layouts/base_layout.mjml.eex", 53 | mode: :compile 54 | end 55 | 56 | defmodule LayoutTemplate do 57 | use MjmlEEx, 58 | mjml_template: "test_templates/layout_template.mjml.eex", 59 | mode: :compile, 60 | layout: BaseLayout 61 | end 62 | 63 | defmodule AssignsLayout do 64 | @moduledoc false 65 | 66 | use MjmlEEx.Layout, 67 | mjml_layout: "test_layouts/assigns_layout.mjml.eex", 68 | mode: :compile 69 | end 70 | 71 | defmodule AssignsLayoutTemplate do 72 | use MjmlEEx, 73 | mjml_template: "test_templates/layout_template.mjml.eex", 74 | mode: :compile, 75 | layout: AssignsLayout 76 | end 77 | 78 | defmodule MjmlEExTest.Gettext do 79 | use Gettext, otp_app: :mjml_eex 80 | end 81 | 82 | defmodule GettextTemplate do 83 | import MjmlEExTest.Gettext 84 | 85 | use MjmlEEx, 86 | mjml_template: "test_templates/gettext_template.mjml.eex", 87 | mode: :compile 88 | end 89 | 90 | describe "BasicTemplate.render/1" do 91 | test "should raise an error if no assigns are provided" do 92 | assert_raise ArgumentError, ~r/assign @call_to_action_text not available in template/, fn -> 93 | BasicTemplate.render([]) 94 | end 95 | end 96 | 97 | test "should render the template and contain the proper text when passed assigns" do 98 | assert BasicTemplate.render(call_to_action_text: "Click me please!") =~ "Click me please!" 99 | end 100 | 101 | test "should escape scripts that are attempted to be added to the template" do 102 | assert BasicTemplate.render(call_to_action_text: " Click me please!") =~ 103 | "<script>alert('Hacked!');</script> Click me please!" 104 | end 105 | end 106 | 107 | describe "ConditionalTemplate.render/1" do 108 | test "should output the correct button depending on the assigns" do 109 | assert ConditionalTemplate.render(all_caps: true) =~ "SIGN UP TODAY!!" 110 | assert ConditionalTemplate.render(all_caps: false) =~ "Sign up today!" 111 | end 112 | end 113 | 114 | describe "GettextTemplate.render/1" do 115 | test "should output the correct output when run with gettext" do 116 | assert GettextTemplate.render([]) =~ "Hello John!" 117 | end 118 | end 119 | 120 | describe "FunctionTemplate.render/1" do 121 | test "should output the correct output when a module function is used" do 122 | assert FunctionTemplate.render(first_name: "Alex", last_name: "Koutmos") =~ "Alex Koutmos" 123 | end 124 | 125 | test "should escape scripts that are attempted to be added to the template" do 126 | assert FunctionTemplate.render(first_name: "", last_name: "Koutmos") =~ 127 | "<script>alert('Hacked!');</script> Koutmos" 128 | end 129 | end 130 | 131 | describe "ErrorTemplate" do 132 | test "should raise an error if the MJML template fails to compile" do 133 | assert_raise RuntimeError, "Failed to compile MJML template: \"unexpected element at position 447..480\"", fn -> 134 | defmodule InvalidTemplateOption do 135 | use MjmlEEx, 136 | mjml_template: "test_templates/invalid_template.mjml.eex", 137 | mode: :compile 138 | end 139 | end 140 | end 141 | 142 | test "should raise an error if the MJML template compile mode is invalid" do 143 | assert_raise RuntimeError, ~r/:yolo is an invalid :mode. Possible values are :runtime or :compile/, fn -> 144 | defmodule InvalidCompileModeOption do 145 | use MjmlEEx, 146 | mjml_template: "test_templates/invalid_template.mjml.eex", 147 | mode: :yolo 148 | end 149 | end 150 | end 151 | 152 | test "should raise an error if the layout option is invalid" do 153 | assert_raise ArgumentError, ~r/could not load module InvalidModule due to reason/, fn -> 154 | defmodule InvalidLayoutOption do 155 | use MjmlEEx, 156 | mjml_template: "test_templates/invalid_template.mjml.eex", 157 | layout: InvalidModule 158 | end 159 | end 160 | end 161 | end 162 | 163 | describe "The use macro" do 164 | test "should fail to compile since a valid mjml template can not be found" do 165 | assert_raise RuntimeError, ~r/The provided :mjml_template does not exist at/, fn -> 166 | defmodule NoTemplateOption do 167 | use MjmlEEx 168 | end 169 | end 170 | end 171 | 172 | test "should fail to compile since the :mjml_template option points to a non-existent file" do 173 | assert_raise RuntimeError, ~r/The provided :mjml_template does not exist at/, fn -> 174 | defmodule NotFoundTemplateOption do 175 | use MjmlEEx, 176 | mjml_template: "does_not_exist.mjml.eex", 177 | mode: :compile 178 | end 179 | end 180 | end 181 | end 182 | 183 | describe "ComponentTemplate.render/1" do 184 | test "should render the document with the head and attribute block" do 185 | assert ComponentTemplate.render(all_caps: true) =~ "SIGN UP TODAY!!" 186 | assert ComponentTemplate.render(all_caps: true) =~ "Montserrat, Helvetica, Arial, sans-serif" 187 | end 188 | end 189 | 190 | describe "DynamicComponentTemplate.render/1" do 191 | test "should render the document with the appropriate assigns" do 192 | rendered_template = DynamicComponentTemplate.render(some_data: 1..5) 193 | 194 | assert rendered_template =~ "Some data - 1" 195 | assert rendered_template =~ "Some data - 2" 196 | assert rendered_template =~ "Some data - 3" 197 | assert rendered_template =~ "Some data - 4" 198 | assert rendered_template =~ "Some data - 5" 199 | end 200 | 201 | test "should emit a telemetry event when the rendering starts and completes" do 202 | # Attach the provided debug logger 203 | Telemetry.attach_logger(level: :info) 204 | 205 | # Attach custom handler 206 | :telemetry.attach_many( 207 | "mjml_eex_test_telemetry", 208 | [ 209 | [:mjml_eex, :render, :start], 210 | [:mjml_eex, :render, :stop] 211 | ], 212 | fn event, measurements, metadata, _opts -> 213 | send(self(), %{event: event, measurements: measurements, metadata: metadata}) 214 | end, 215 | nil 216 | ) 217 | 218 | assert capture_log(fn -> 219 | DynamicComponentTemplate.render(some_data: 1..5) 220 | end) =~ "Measurements:" 221 | 222 | # Check the start event 223 | assert_received %{event: [:mjml_eex, :render, :start], measurements: measurements, metadata: metadata} 224 | assert Map.has_key?(measurements, :system_time) 225 | 226 | Enum.each([:compiler, :mode, :assigns, :mjml_template, :mjml_template_file, :layout_module], fn key -> 227 | assert Map.has_key?(metadata, key) 228 | end) 229 | 230 | # Check the stop event 231 | assert_received %{event: [:mjml_eex, :render, :stop], measurements: measurements, metadata: metadata} 232 | assert Map.has_key?(measurements, :duration) 233 | 234 | Enum.each( 235 | [:compiler, :mode, :assigns, :mjml_template, :mjml_template_file, :layout_module, :rendered_html], 236 | fn key -> 237 | assert Map.has_key?(metadata, key) 238 | end 239 | ) 240 | after 241 | Telemetry.detach_logger() 242 | :telemetry.detach("mjml_eex_test_telemetry") 243 | end 244 | end 245 | 246 | describe "CompileTimeDynamicComponentTemplate.render/1" do 247 | test "should raise an error if a dynamic component is rendered at compile time" do 248 | assert_raise RuntimeError, 249 | ~r/render_dynamic_component can only be used with runtime generated templates. Switch your template to `mode: :runtime`/, 250 | fn -> 251 | defmodule CompileTimeDynamicComponentTemplate do 252 | use MjmlEEx, 253 | mjml_template: "test_templates/dynamic_component_template.mjml.eex", 254 | mode: :compile 255 | end 256 | end 257 | end 258 | end 259 | 260 | describe "InvalidDynamicComponentTemplate.render/1" do 261 | test "should raise an error as dynamic components cannot render other dynamic components" do 262 | assert_raise RuntimeError, 263 | ~r/Cannot call `render_dynamic_component` inside of another dynamically rendered component/, 264 | fn -> 265 | InvalidDynamicComponentTemplate.render(some_data: 1..5) 266 | end 267 | end 268 | end 269 | 270 | describe "BadExpressionDynamicComponentTemplate" do 271 | test "should fail to compile since the render_dynamic_component call is not in an = expression" do 272 | assert_raise RuntimeError, 273 | ~r/render_dynamic_component can only be invoked inside of an <%= ... %> expression/, 274 | fn -> 275 | defmodule BadExpressionDynamicComponentTemplate do 276 | use MjmlEEx, 277 | mjml_template: "test_templates/bad_expression_dynamic_component_template.mjml.eex", 278 | mode: :runtime 279 | end 280 | end 281 | end 282 | end 283 | 284 | describe "InvalidComponentTemplate" do 285 | test "should fail to compile since the render_static_component call is not in an = expression" do 286 | assert_raise RuntimeError, 287 | ~r/render_static_component can only be invoked inside of an <%= ... %> expression/, 288 | fn -> 289 | defmodule InvalidTemplateOption do 290 | use MjmlEEx, 291 | mjml_template: "test_templates/invalid_component_template.mjml.eex", 292 | mode: :compile 293 | end 294 | end 295 | end 296 | end 297 | 298 | describe "LayoutTemplate.render/1" do 299 | test "should raise an error if no assigns are provided" do 300 | assert_raise ArgumentError, ~r/assign @call_to_action_text not available in template/, fn -> 301 | LayoutTemplate.render([]) 302 | end 303 | end 304 | 305 | test "should render the template using a layout" do 306 | assert LayoutTemplate.render(call_to_action_text: "Click me please!") =~ "Click me please!" 307 | end 308 | 309 | test "should escape scripts that are attempted to be added to the template" do 310 | assert LayoutTemplate.render(call_to_action_text: "Click me please!") =~ 311 | "<script>alert('Hacked!');</script>Click me please!" 312 | end 313 | end 314 | 315 | describe "AssignsTemplate.render/1" do 316 | test "should raise an error if no assigns are provided" do 317 | assert_raise ArgumentError, ~r/assign @padding not available in template/, fn -> 318 | AssignsLayoutTemplate.render([]) 319 | end 320 | end 321 | 322 | test "should render the template using a layout" do 323 | assert AssignsLayoutTemplate.render(call_to_action_text: "Click me please!", padding: "0px") =~ "Click me please!" 324 | end 325 | end 326 | 327 | describe "InvalidLayout" do 328 | test "should fail to compile since the layout contains no @inner_content expressions" do 329 | assert_raise RuntimeError, ~r/The provided :mjml_layout must contain one <%= @inner_content %> expression./, fn -> 330 | defmodule InvalidLayout do 331 | use MjmlEEx.Layout, 332 | mjml_layout: "test_layouts/invalid_layout.mjml.eex", 333 | mode: :compile 334 | end 335 | end 336 | end 337 | end 338 | 339 | describe "OtherInvalidLayout" do 340 | test "should fail to compile since the layout contains 2 @inner_content expressions" do 341 | assert_raise RuntimeError, 342 | ~r/The provided :mjml_layout contains multiple <%= @inner_content %> expressions./, 343 | fn -> 344 | defmodule OtherInvalidLayout do 345 | use MjmlEEx.Layout, 346 | mjml_layout: "test_layouts/other_invalid_layout.mjml.eex", 347 | mode: :compile 348 | end 349 | end 350 | end 351 | end 352 | 353 | describe "MissingOptionLayout" do 354 | test "should fail to compile since the use statement is missing a required option" do 355 | assert_raise RuntimeError, ~r/The :mjml_layout option is required./, fn -> 356 | defmodule MissingOptionLayout do 357 | use MjmlEEx.Layout 358 | end 359 | end 360 | end 361 | end 362 | 363 | describe "MissingFileLayout" do 364 | test "should fail to compile since the use statement is missing a required option" do 365 | assert_raise RuntimeError, ~r/The provided :mjml_layout does not exist at/, fn -> 366 | defmodule MissingFileLayout do 367 | use MjmlEEx.Layout, 368 | mode: :compile, 369 | mjml_layout: "invalid/path/to/layout.mjml.eex" 370 | end 371 | end 372 | end 373 | end 374 | end 375 | -------------------------------------------------------------------------------- /test/node_compiler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NodeCompilerTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule BasicTemplate do 5 | use MjmlEEx, 6 | mjml_template: "test_templates/basic_template.mjml.eex" 7 | end 8 | 9 | setup_all do 10 | Application.ensure_started(:erlexec) 11 | end 12 | 13 | setup do 14 | path = System.get_env("MJML_CLI_PATH", "mjml") 15 | 16 | Application.put_env(:mjml_eex, :compiler, MjmlEEx.Compilers.Node) 17 | Application.put_env(:mjml_eex, :compiler_opts, path: path) 18 | end 19 | 20 | describe "BasicTemplate.render/1" do 21 | test "should render the template and contain the proper text when passed assigns" do 22 | Application.put_env(:mjml_eex, :compiler, MjmlEEx.Compilers.Node) 23 | 24 | assert BasicTemplate.render(call_to_action_text: "Click me please!") =~ "Click me please!" 25 | after 26 | set_default_config() 27 | end 28 | 29 | test "should raise an error if the timeout is set too low for rendering" do 30 | Application.put_env(:mjml_eex, :compiler, MjmlEEx.Compilers.Node) 31 | Application.put_env(:mjml_eex, :compiler_opts, timeout: 5) 32 | 33 | assert_raise RuntimeError, 34 | ~r/Node mjml CLI compiler timed out after 0 second\(s\)/, 35 | fn -> 36 | BasicTemplate.render(call_to_action_text: "Click me please!") 37 | end 38 | after 39 | set_default_config() 40 | end 41 | 42 | test "should raise an error if the mjml node cli tool is unavailable" do 43 | Application.put_env(:mjml_eex, :compiler, MjmlEEx.Compilers.Node) 44 | Application.put_env(:mjml_eex, :compiler_opts, path: "totally_not_a_real_cli_compiler") 45 | 46 | assert_raise RuntimeError, 47 | ~r/Node mjml CLI compiler exited with status code 32512/, 48 | fn -> 49 | BasicTemplate.render(call_to_action_text: "Click me please!") 50 | end 51 | after 52 | set_default_config() 53 | end 54 | end 55 | 56 | defp set_default_config do 57 | Application.put_env(:mjml_eex, :compiler, MjmlEEx.Compilers.Rust) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/test_components/attribute_block.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.TestComponents.AttributeBlock do 2 | @moduledoc """ 3 | This module defines the MJML component for the shared attribute block. 4 | """ 5 | 6 | use MjmlEEx.Component 7 | 8 | @impl true 9 | def render(_opts) do 10 | """ 11 | 12 | 13 | 14 | 15 | 16 | """ 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/test_components/dynamic_component.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.TestComponents.DynamicComponent do 2 | @moduledoc """ 3 | This module defines the MJML component for the shared head block. 4 | """ 5 | 6 | use MjmlEEx.Component 7 | 8 | @impl true 9 | def render(data: data) do 10 | """ 11 |

12 | #{data} 13 |

14 | """ 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/test_components/head_block.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.TestComponents.HeadBlock do 2 | @moduledoc """ 3 | This module defines the MJML component for the shared head block. 4 | """ 5 | 6 | use MjmlEEx.Component 7 | 8 | @impl true 9 | def render(opts) do 10 | # Merge default options with whatever was passed in 11 | defaults = [title: "Welcome!", font: "Roboto"] 12 | opts = Keyword.merge(defaults, opts) 13 | 14 | """ 15 | 16 | #{opts[:title]} 17 | 18 | <%= render_static_component MjmlEEx.TestComponents.AttributeBlock %> 19 | 20 | """ 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/test_components/invalid_dynamic_component.ex: -------------------------------------------------------------------------------- 1 | defmodule MjmlEEx.TestComponents.InvalidDynamicComponent do 2 | @moduledoc """ 3 | This module defines the MJML component for the shared head block. 4 | """ 5 | 6 | use MjmlEEx.Component 7 | 8 | @impl true 9 | def render(data: _data) do 10 | """ 11 |

12 | <%= render_dynamic_component MjmlEEx.TestComponents.DynamicComponent %> 13 |

14 | """ 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/test_layouts/assigns_layout.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | Say hello to card 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%= @inner_content %> 12 | 13 | -------------------------------------------------------------------------------- /test/test_layouts/base_layout.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | Say hello to card 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%= @inner_content %> 12 | 13 | -------------------------------------------------------------------------------- /test/test_layouts/invalid_layout.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | Say hello to card 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/test_layouts/other_invalid_layout.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | Say hello to card 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <%= @inner_content %> 13 | <%= @inner_content %> 14 | 15 | -------------------------------------------------------------------------------- /test/test_templates/bad_expression_dynamic_component_template.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | <% render_dynamic_component(MjmlEEx.TestComponents.HeadBlock, [title: "Hello!"]) %> 3 | 4 | 5 | 6 | Writing A Good Headline For Your Advertisement 7 | 8 | 9 | 10 | 11 | // BR&AND 12 | 13 | 14 | HOME   /   SERVICE   /   THIRD 15 | 16 | 17 | 18 | 19 | Free Advertising For Your Online Business. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | A Right Media Mix Can Make The Difference. 30 | 31 | 32 | 33 | 34 | 35 | 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. 36 | 37 | <%= @call_to_action_text %> 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
49 |
50 |
51 |
52 |
53 | -------------------------------------------------------------------------------- /test/test_templates/basic_template.mjml.eex: -------------------------------------------------------------------------------- 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 | 45 | <%= @call_to_action_text %> 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
57 |
58 |
59 |
60 |
61 | -------------------------------------------------------------------------------- /test/test_templates/component_template.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | <%= render_static_component(MjmlEEx.TestComponents.HeadBlock, [title: "Hello!"]) %> 3 | 4 | 5 | 6 | Writing A Good Headline For Your Advertisement 7 | 8 | 9 | 10 | 11 | // BR&AND 12 | 13 | 14 | HOME   /   SERVICE   /   THIRD 15 | 16 | 17 | 18 | 19 | Free Advertising For Your Online Business. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | A Right Media Mix Can Make The Difference. 30 | 31 | 32 | 33 | 34 | 35 | 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. 36 | 37 | SIGN UP TODAY!! 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
49 |
50 |
51 |
52 |
53 | -------------------------------------------------------------------------------- /test/test_templates/conditional_template.mjml.eex: -------------------------------------------------------------------------------- 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 | 45 | <%= if @all_caps do %> 46 | SIGN UP TODAY!! 47 | <% else %> 48 | Sign up today! 49 | <% end %> 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
61 |
62 |
63 |
64 |
65 | -------------------------------------------------------------------------------- /test/test_templates/dynamic_component_template.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | <%= render_dynamic_component MjmlEEx.TestComponents.HeadBlock %> 3 | 4 | 5 | 6 | Writing A Good Headline For Your Advertisement 7 | 8 | 9 | 10 | 11 | // BR&AND 12 | 13 | 14 | HOME   /   SERVICE   /   THIRD 15 | 16 | 17 | 18 | 19 | Free Advertising For Your Online Business. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | A Right Media Mix Can Make The Difference. 30 | 31 | 32 | 33 | 34 | 35 | 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. 36 | <%= for index <- @some_data do %> 37 | <%= render_dynamic_component MjmlEEx.TestComponents.DynamicComponent, data: "Some data - #{index}" %> 38 | <% end %> 39 | 40 | SIGN UP TODAY!! 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
52 |
53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /test/test_templates/function_template.mjml.eex: -------------------------------------------------------------------------------- 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 | 45 | <%= generate_full_name(@first_name, @last_name) %> 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
57 |
58 |
59 |
60 |
61 | -------------------------------------------------------------------------------- /test/test_templates/gettext_template.mjml.eex: -------------------------------------------------------------------------------- 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 | 45 | <%= gettext "Hello" %> John! 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
57 |
58 |
59 |
60 |
61 | -------------------------------------------------------------------------------- /test/test_templates/invalid_component_template.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | <% render_static_component(MjmlEEx.TestComponents.HeadBlock, [title: "Hello!"]) %> 3 | 4 | 5 | 6 | Writing A Good Headline For Your Advertisement 7 | 8 | 9 | 10 | 11 | // BR&AND 12 | 13 | 14 | HOME   /   SERVICE   /   THIRD 15 | 16 | 17 | 18 | 19 | Free Advertising For Your Online Business. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | A Right Media Mix Can Make The Difference. 30 | 31 | 32 | 33 | 34 | 35 | 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. 36 | 37 | <%= @call_to_action_text %> 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
49 |
50 |
51 |
52 |
53 | -------------------------------------------------------------------------------- /test/test_templates/invalid_dynamic_component_template.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | <%= render_static_component(MjmlEEx.TestComponents.HeadBlock, [title: "Hello!"]) %> 3 | 4 | 5 | 6 | Writing A Good Headline For Your Advertisement 7 | 8 | 9 | 10 | 11 | // BR&AND 12 | 13 | 14 | HOME   /   SERVICE   /   THIRD 15 | 16 | 17 | 18 | 19 | Free Advertising For Your Online Business. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | A Right Media Mix Can Make The Difference. 30 | 31 | 32 | 33 | 34 | 35 | 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. 36 | <%= for index <- @some_data do %> 37 | <%= render_dynamic_component MjmlEEx.TestComponents.InvalidDynamicComponent, data: "Some data - #{index}" %> 38 | <% end %> 39 | 40 | SIGN UP TODAY!! 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
52 |
53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /test/test_templates/invalid_template.mjml.eex: -------------------------------------------------------------------------------- 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 | 45 | <%= @call_to_action_text %> 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
57 |
58 |
59 | 60 |
61 | -------------------------------------------------------------------------------- /test/test_templates/layout_template.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Writing A Good Headline For Your Advertisement 5 | 6 | 7 | 8 | 9 | // BR&AND 10 | 11 | 12 | HOME   /   SERVICE   /   THIRD 13 | 14 | 15 | 16 | 17 | Free Advertising For Your Online Business. 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | A Right Media Mix Can Make The Difference. 28 | 29 | 30 | 31 | 32 | 33 | 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. 34 | 35 | <%= @call_to_action_text %> 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
47 |
48 |
49 |
50 | --------------------------------------------------------------------------------