├── VERSION ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── test ├── test_helper.exs ├── test_components │ ├── dynamic_component.ex │ ├── invalid_dynamic_component.ex │ ├── attribute_block.ex │ └── head_block.ex ├── test_layouts │ ├── invalid_layout.mjml.eex │ ├── base_layout.mjml.eex │ ├── assigns_layout.mjml.eex │ └── other_invalid_layout.mjml.eex ├── node_compiler_test.exs ├── test_templates │ ├── layout_template.mjml.eex │ ├── component_template.mjml.eex │ ├── invalid_component_template.mjml.eex │ ├── bad_expression_dynamic_component_template.mjml.eex │ ├── dynamic_component_template.mjml.eex │ ├── invalid_dynamic_component_template.mjml.eex │ ├── basic_template.mjml.eex │ ├── gettext_template.mjml.eex │ ├── function_template.mjml.eex │ ├── invalid_template.mjml.eex │ └── conditional_template.mjml.eex └── mjml_eex_test.exs ├── .tool-versions ├── coveralls.json ├── guides └── images │ ├── your_logo_here.png │ └── logo.svg ├── .formatter.exs ├── lib ├── compiler.ex ├── compilers │ ├── rust.ex │ └── node.ex ├── mjml_eex │ ├── component.ex │ └── layout.ex ├── telemetry.ex ├── utils.ex ├── engines │ └── mjml.ex └── mjml_eex.ex ├── .doctor.exs ├── config └── config.exs ├── .gitignore ├── LICENSE ├── mix.exs ├── CHANGELOG.md ├── mix.lock ├── .credo.exs └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 0.13.0 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [akoutmos] 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.19.4-otp-28 2 | erlang 28.2 3 | rust 1.92.0 4 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /guides/images/your_logo_here.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/mjml_eex/HEAD/guides/images/your_logo_here.png -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | line_length: 120, 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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_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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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.19.4" 25 | otp-version: "28.2" 26 | - name: Restore dependencies cache 27 | uses: actions/cache@v4 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@v4 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: "27" 57 | elixir: "1.17" 58 | - otp: "27" 59 | elixir: "1.18" 60 | - otp: "28" 61 | elixir: "1.19" 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@v4 73 | with: 74 | node-version: 20 75 | - name: Restore dependencies cache 76 | uses: actions/cache@v4 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 | -------------------------------------------------------------------------------- /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 | package: package(), 17 | deps: deps(), 18 | docs: docs(), 19 | aliases: aliases() 20 | ] 21 | end 22 | 23 | # Run "mix help compile.app" to learn about applications. 24 | def application do 25 | [ 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | def cli do 31 | [ 32 | preferred_envs: [ 33 | coveralls: :test, 34 | "coveralls.detail": :test, 35 | "coveralls.post": :test, 36 | "coveralls.html": :test, 37 | "coveralls.github": :test 38 | ] 39 | ] 40 | end 41 | 42 | # Specifies which paths to compile per environment. 43 | defp elixirc_paths(:test), do: ["lib", "test/test_components", "test/test_layouts"] 44 | defp elixirc_paths(_), do: ["lib"] 45 | 46 | defp package do 47 | [ 48 | name: "mjml_eex", 49 | files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md VERSION), 50 | licenses: ["MIT"], 51 | maintainers: ["Alex Koutmos"], 52 | links: %{ 53 | "GitHub" => "https://github.com/akoutmos/mjml_eex", 54 | "Sponsor" => "https://github.com/sponsors/akoutmos" 55 | } 56 | ] 57 | end 58 | 59 | defp docs do 60 | [ 61 | main: "readme", 62 | source_ref: "master", 63 | logo: "guides/images/logo.svg", 64 | extras: ["README.md"] 65 | ] 66 | end 67 | 68 | # Run "mix help deps" to learn about dependencies. 69 | defp deps do 70 | [ 71 | # Production deps 72 | {:mjml, "~> 4.0 or ~> 5.0"}, 73 | {:phoenix_html, "~> 3.2 or ~> 4.0"}, 74 | {:telemetry, "~> 1.0"}, 75 | {:erlexec, "~> 2.2", optional: true}, 76 | 77 | # Development deps 78 | {:gettext, "~> 1.0", only: :test}, 79 | {:ex_doc, "~> 0.34", only: :dev}, 80 | {:excoveralls, "~> 0.18", only: [:test, :dev], runtime: false}, 81 | {:doctor, "~> 0.21", only: :dev}, 82 | {:credo, "~> 1.7", only: :dev}, 83 | {:git_hooks, "~> 0.7", only: [:test, :dev], runtime: false} 84 | ] 85 | end 86 | 87 | defp aliases do 88 | [ 89 | docs: ["docs", ©_files/1] 90 | ] 91 | end 92 | 93 | defp project_version do 94 | "VERSION" 95 | |> File.read!() 96 | |> String.trim() 97 | end 98 | 99 | defp copy_files(_) do 100 | # Set up directory structure 101 | File.mkdir_p!("./doc/guides/images") 102 | 103 | # Copy over image files 104 | "./guides/images/" 105 | |> File.ls!() 106 | |> Enum.each(fn image_file -> 107 | File.cp!("./guides/images/#{image_file}", "./doc/guides/images/#{image_file}") 108 | end) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/bad_expression_dynamic_component_template.mjml.eex: -------------------------------------------------------------------------------- 1 | 2 | <% render_dynamic_component(MjmlEEx.TestComponents.HeadBlock, [title: "Hello!"]) %> 3 | 4 | <%!-- this is some comment --%> 5 | 6 | 7 | 8 | Writing A Good Headline For Your Advertisement 9 | 10 | 11 | 12 | 13 | // BR&AND 14 | 15 | 16 | HOME   /   SERVICE   /   THIRD 17 | 18 | 19 | 20 | 21 | Free Advertising For Your Online Business. 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | A Right Media Mix Can Make The Difference. 32 | 33 | 34 | 35 | 36 | 37 | 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. 38 | 39 | <%= @call_to_action_text %> 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Unsubscribe from this newsletter
52 Edison Court Suite 259 / East Aidabury / Cambodi
Made by svenhaustein.de
51 |
52 |
53 |
54 |
55 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | {:comment, _content, _location}, acc -> 101 | acc 102 | 103 | {:eof, _location}, acc -> 104 | acc 105 | end) 106 | end 107 | 108 | defp normalize_marker([]), do: "" 109 | defp normalize_marker(marker), do: List.to_string(marker) 110 | end 111 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.13.0] - 2025-12-12 11 | 12 | - Upgraded to MJML 5.3 13 | - Allow comments (e.g. `<%!-- this is some comment --%>`) in templates 14 | - Bump optional `erlexec` dependency to `2.2` 15 | 16 | ## [0.12.0] - 2024-07-12 17 | 18 | - Upgraded to MJML 4.0.0 19 | 20 | ## [0.11.0] - 2024-06-20 21 | 22 | ### Changed 23 | 24 | - Upgraded dependencies 25 | - Removed forked Elixir Tokenizier 26 | 27 | ## [0.10.0] - 2024-02-19 28 | 29 | ### Changed 30 | 31 | - Upgrades the dependencies to the latest versions. 32 | 33 | ## [0.9.1] - 2022-02-10 34 | 35 | ### Changed 36 | 37 | - Relax `phoenix_html` to make it compatible with phoenix 1.7. 38 | - Upgrades the dependencies to the latest version. 39 | 40 | ## [0.9.0] - 2022-09-05 41 | 42 | ### Added 43 | 44 | - MJML EEx now has Telemetry support for the rendering process. Take a look at the 45 | `MjmlEEx.Telemetry` module for more details. 46 | 47 | ### Changed 48 | 49 | - The configuration options that are passed to MJML EEx have change in structure 50 | and there is now a `:compiler_opts` entry for options that are passed to the 51 | configured compiler. 52 | 53 | ## [0.8.1] - 2022-07-22 54 | 55 | ### Fixed 56 | 57 | - Removed `:erlexec` as an `:extra_application` so it does not cause compilation errors. 58 | 59 | ## [0.8.0] - 2022-07-22 60 | 61 | ### Changed 62 | 63 | - `:erlexec` is now an optional dependency. If you attempt to use the Node compiler without this dependency 64 | an error will be raised. The error message contains information on pulling it down and starting the `:erlexec` 65 | application. 66 | 67 | ## [0.7.0] - 2022-05-26 68 | 69 | ### Added 70 | 71 | - You can now chose your MJML compiler. By default the Rust NIF compiler is used, but there is also an 72 | adapter for the Node MJML compiler. 73 | 74 | ## [0.6.0] - 2022-05-06 75 | 76 | ### Added 77 | 78 | - The `render_static_component` function can be used to render components that don't make use of any assigns. For 79 | example, in your template you would have: `<%= render_static_component MyCoolComponent, static: "data" %>` and this 80 | can be rendered at compile time as well as runtime. 81 | - The `render_dynamic_component` function can be used to render components that make use of assigns at runtime. For 82 | example, in your template you would have: `<%= render_dynamic_component MyCoolComponent, static: @data %>`. 83 | 84 | ### Changed 85 | 86 | - When calling `use MjmlEEx`, if the `:mjml_template` option is not provided, the module attempts to find a template 87 | file in the same directory that has the same file name as the module (with the `.mjml.eex` extension instead 88 | of `.ex`). This functions similar to how Phoenix and LiveView handle their templates. 89 | 90 | ### Removed 91 | 92 | - `render_component` is no longer available and users should now use `render_static_component` or 93 | `render_dynamic_component`. 94 | 95 | ## [0.5.0] - 2022-04-28 96 | 97 | ### Added 98 | 99 | - Templates can now either be compiled at runtime or at compile time based on the options passed to `use MjmlEEx` 100 | 101 | ## [0.4.0] - 2022-04-27 102 | 103 | ### Fixed 104 | 105 | - Calls to `render_component` now evaluate the AST aliases in the context of the `__CALLER__` 106 | - EEx templates, components and layouts are tokenized prior to going through the MJML EEx engine as not to escape MJML content 107 | 108 | ## [0.3.0] - 2022-04-17 109 | 110 | ### Added 111 | 112 | - Ability to inject a template into a layout 113 | 114 | ## [0.2.0] - 2022-04-15 115 | 116 | ### Added 117 | 118 | - Ability to render MJML component partials in MJML templates via `render_component` 119 | - Macros for MJML templates 120 | - Custom EEx engine to compile MJML EEx template to HTML 121 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, 5 | "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [: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", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, 6 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 | "doctor": {:hex, :doctor, "0.22.0", "223e1cace1f16a38eda4113a5c435fa9b10d804aa72d3d9f9a71c471cc958fe7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "96e22cf8c0df2e9777dc55ebaa5798329b9028889c4023fed3305688d902cd5b"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 9 | "erlexec": {:hex, :erlexec, "2.2.2", "edb9f1a7d821a9df4efdc82bd11817ab5249dbd294d5c54ec249bd905f10e804", [:rebar3], [], "hexpm", "5e8e3c3773113785361b3b55218d92f7e91509cc9d679bf67c5c3703b394c900"}, 10 | "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 12 | "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, 13 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 14 | "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, 15 | "git_hooks": {:hex, :git_hooks, "0.8.1", "1f6a1b065638e07ed89a49804dac6c24d8ac8d27c8f9fd0e9620d5bef8c30f41", [:mix], [{:recase, "~> 0.8.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "267d8b82615ad439177b2a4bc2efadb7491ec1c8520dacc67ddc38c251448cbc"}, 16 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 17 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 20 | "mjml": {:hex, :mjml, "5.3.0", "e24f39b9807185dc8dd773f0e80b917a05d9024cf4ff6c86857b4988e6a0926f", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8.3", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "58b90a298366daac55314ecd9531711ac16516e1d4b943a24d4b9d1f57e43918"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 22 | "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, 23 | "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, 24 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, 25 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 26 | } 27 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.13.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 | ```elixir 83 | defmodule BasicTemplate do 84 | use MjmlEEx, mjml_template: "basic_template.mjml.eex" 85 | end 86 | ``` 87 | 88 | And the accompanying MJML EEx template `basic_template.mjml.eex` (note that the path is relative to the calling 89 | module path): 90 | 91 | ```html 92 | 93 | 94 | 95 | 96 | 97 | 98 | Hello <%= @first_name %> <%= @last_name %>! 99 | 100 | 101 | 102 | 103 | 104 | ``` 105 | 106 | With those two in place, you can now run `BasicTemplate.render(first_name: "Alex", last_name: "Koutmos")` and you 107 | will get back an HTML document that can be emailed to users. 108 | 109 | ### Using Functions from Template Module 110 | 111 | You can also call functions from your template module if they exist in your MJML EEx template using 112 | the following module declaration: 113 | 114 | ```elixir 115 | defmodule FunctionTemplate do 116 | use MjmlEEx, mjml_template: "function_template.mjml.eex" 117 | 118 | defp generate_full_name(first_name, last_name) do 119 | "#{first_name} #{last_name}" 120 | end 121 | end 122 | ``` 123 | 124 | In conjunction with the following template: 125 | 126 | ```html 127 | 128 | 129 | 130 | 131 | 132 | 133 | Hello <%= generate_full_name(@first_name, @last_name) %>! 134 | 135 | 136 | 137 | 138 | 139 | ``` 140 | 141 | In order to render the email you would then call: `FunctionTemplate.render(first_name: "Alex", last_name: "Koutmos")` 142 | 143 | ### Using Components 144 | 145 | **Static components** 146 | 147 | In addition to compiling single MJML EEx templates, you can also create MJML partials and include them 148 | in other MJML templates AND components using the special `render_static_component` function. With the following 149 | modules: 150 | 151 | ```elixir 152 | defmodule FunctionTemplate do 153 | use MjmlEEx, mjml_template: "component_template.mjml.eex" 154 | end 155 | ``` 156 | 157 | ```elixir 158 | defmodule HeadBlock do 159 | use MjmlEEx.Component 160 | 161 | @impl true 162 | def render(_opts) do 163 | """ 164 | 165 | Hello world! 166 | 167 | 168 | """ 169 | end 170 | end 171 | ``` 172 | 173 | And the following template: 174 | 175 | ```html 176 | 177 | <%= render_static_component HeadBlock %> 178 | 179 | 180 | 181 | 182 | 183 | 184 | Hello <%= generate_full_name(@first_name, @last_name) %>! 185 | 186 | 187 | 188 | 189 | 190 | ``` 191 | 192 | Be sure to look at the `MjmlEEx.Component` module for additional usage information as you can also pass options to your 193 | template and use them when generating the partial string. One thing to note is that when using 194 | `render_static_component`, the data that is passed to the component must be defined at compile time. This means that you 195 | cannot use any assigns that would be evaluated at runtime. For example, this would raise an error: 196 | 197 | ```elixir 198 | 199 | <%= render_static_component MyTextComponent, some_data: @some_data %> 200 | 201 | ``` 202 | 203 | **Dynamic components** 204 | 205 | If you need to render your components dynamically, use `render_dynamic_component` instead and be sure to configure your 206 | template module like below to generate the email HTML at runtime. First, you create your component, for example, `MyTemplate.CtaComponent.ex`: 207 | 208 | ```elixir 209 | def MyTemplate.CtaComponent do 210 | use MjmlEEx.Component, mode: :runtime 211 | 212 | @impl MjmlEEx.Component 213 | def render(assigns) do 214 | """ 215 | 216 | 217 | #{assigns[:call_to_action_text]} 218 | 219 | 220 | #{assigns[:call_to_action_link]} 221 | 222 | 223 | """ 224 | end 225 | end 226 | ``` 227 | 228 | then, in your MJML template, insert it using the `render_dynamic_template_component` function: 229 | 230 | ```html 231 | 232 | 233 | 234 | 235 | 236 | <%= render_dynamic_component MyTemplate.CtaComponent %{call_to_action_text: "Call to action text", 237 | call_to_action_link: "#{@cta_link}"} %> 238 | 239 | 240 | 241 | 242 | ``` 243 | 244 | In your `UserNotifier` module, or equivalent, you render your template, passing any assigns/data it expects: 245 | 246 | ```Elixir 247 | WelcomeEmail.render(call_to_action_text: call_to_action_text, call_to_action_link: call_to_action_link) 248 | ``` 249 | 250 | ### Using Layouts 251 | 252 | Often times, you'll want to create an Email skeleton or layout using MJML, and then inject your template into that 253 | layout. MJML EEx supports this functionality which makes it really easy to have business branded emails application 254 | wide without having to copy and paste the same boilerplate in every template. 255 | 256 | To create a layout, define a layout module like so: 257 | 258 | ```elixir 259 | defmodule BaseLayout do 260 | use MjmlEEx.Layout, mjml_layout: "base_layout.mjml.eex" 261 | end 262 | ``` 263 | 264 | And an accompanying layout like so: 265 | 266 | ```html 267 | 268 | 269 | Say hello to card 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | <%= @inner_content %> 279 | 280 | ``` 281 | 282 | As you can see, you can include assigns in your layout template (like `@padding`), but you also need to 283 | include a mandatory `@inner_content` expression. That way, MJML EEx knowns where to inject your template 284 | into the layout. With that in place, you just need to tell your template module what layout to use (if 285 | you are using a layout that is): 286 | 287 | ```elixir 288 | defmodule MyTemplate do 289 | use MjmlEEx, 290 | mjml_template: "my_template.mjml.eex", 291 | layout: BaseLayout 292 | end 293 | ``` 294 | 295 | And your template file can contain merely the parts that you need for that particular template: 296 | 297 | ```html 298 | ... 299 | ``` 300 | 301 | ## Using with Gettext 302 | 303 | Similarly to Phoenix live/dead views, you can leverage Gettext to produce translated emails. To use Gettext, you will 304 | need to have a Gettext module defined in your project (this should be created automatically for you when you create your 305 | Phoenix project via `mix phx.new MyApp`). Then your MjmlEEx module will look something like this: 306 | 307 | ```elixir 308 | defmodule MyApp.GettextTemplate do 309 | import MyApp.Gettext 310 | 311 | use MjmlEEx, 312 | mjml_template: "gettext_template.mjml.eex", 313 | mode: :compile 314 | end 315 | ``` 316 | 317 | Make sure that you have the `import MyApp.Gettext` statement before the `use MjmlEEx` statement as you will get a 318 | compiler error that the `gettext` function that is being called in the `gettext_template.mjml.eex` has not been defined. 319 | 320 | ## Configuration 321 | 322 | MJML EEx has support for both the 1st party [NodeJS compiler](https://github.com/mjmlio/mjml) and the 3rd party 323 | [Rust compiler](https://github.com/jdrouet/mrml). By default, MJML EEx uses the Rust compiler as there is an 324 | Elixir NIF built with [Rustler](https://github.com/rusterlium/rustler) that packages the Rust 325 | library for easy use: [mjml_nif](https://github.com/adoptoposs/mjml_nif). By default the Rust compiler is used 326 | as it does not require you to have NodeJS available. 327 | 328 | In order to use the NodeJS compiler, you can provide the following configuration in your `config.exs` file: 329 | 330 | ```elixir 331 | config :mjml_eex, compiler: MjmlEEx.Compilers.Node 332 | ``` 333 | 334 | Be sure to check out the documentation for the `MjmlEEx.Compilers.Node` module as it also requires some 335 | additional set up. 336 | 337 | ## Attribution 338 | 339 | - The logo for the project is an edited version of an SVG image from the [unDraw project](https://undraw.co/) 340 | - The Elixir MJML library that this library builds on top of [MJML](https://github.com/adoptoposs/mjml_nif) 341 | - The Rust MRML library that provides the MJML compilation functionality [MRML](https://github.com/jdrouet/mrml) 342 | -------------------------------------------------------------------------------- /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.Backend, otp_app: :mjml_eex 80 | end 81 | 82 | defmodule GettextTemplate do 83 | use Gettext, backend: MjmlEExTest.Gettext 84 | 85 | use MjmlEEx, 86 | mjml_template: "test_templates/gettext_template.mjml.eex", 87 | mode: :compile 88 | end 89 | 90 | def handle_telemetry(event, measurements, metadata, _opts) do 91 | send(self(), %{event: event, measurements: measurements, metadata: metadata}) 92 | end 93 | 94 | describe "BasicTemplate.render/1" do 95 | test "should raise an error if no assigns are provided" do 96 | assert_raise ArgumentError, ~r/assign @call_to_action_text not available in template/, fn -> 97 | BasicTemplate.render([]) 98 | end 99 | end 100 | 101 | test "should render the template and contain the proper text when passed assigns" do 102 | assert BasicTemplate.render(call_to_action_text: "Click me please!") =~ "Click me please!" 103 | end 104 | 105 | test "should escape scripts that are attempted to be added to the template" do 106 | assert BasicTemplate.render(call_to_action_text: " Click me please!") =~ 107 | "<script>alert('Hacked!');</script> Click me please!" 108 | end 109 | end 110 | 111 | describe "ConditionalTemplate.render/1" do 112 | test "should output the correct button depending on the assigns" do 113 | assert ConditionalTemplate.render(all_caps: true) =~ "SIGN UP TODAY!!" 114 | assert ConditionalTemplate.render(all_caps: false) =~ "Sign up today!" 115 | end 116 | end 117 | 118 | describe "GettextTemplate.render/1" do 119 | test "should output the correct output when run with gettext" do 120 | assert GettextTemplate.render([]) =~ "Hello John!" 121 | end 122 | end 123 | 124 | describe "FunctionTemplate.render/1" do 125 | test "should output the correct output when a module function is used" do 126 | assert FunctionTemplate.render(first_name: "Alex", last_name: "Koutmos") =~ "Alex Koutmos" 127 | end 128 | 129 | test "should escape scripts that are attempted to be added to the template" do 130 | assert FunctionTemplate.render(first_name: "", last_name: "Koutmos") =~ 131 | "<script>alert('Hacked!');</script> Koutmos" 132 | end 133 | end 134 | 135 | describe "ErrorTemplate" do 136 | test "should raise an error if the MJML template fails to compile" do 137 | assert_raise RuntimeError, 138 | "Failed to compile MJML template: \"unexpected element in root template at position 447:480\"", 139 | fn -> 140 | defmodule InvalidTemplateOption do 141 | use MjmlEEx, 142 | mjml_template: "test_templates/invalid_template.mjml.eex", 143 | mode: :compile 144 | end 145 | end 146 | end 147 | 148 | test "should raise an error if the MJML template compile mode is invalid" do 149 | assert_raise RuntimeError, ~r/:yolo is an invalid :mode. Possible values are :runtime or :compile/, fn -> 150 | defmodule InvalidCompileModeOption do 151 | use MjmlEEx, 152 | mjml_template: "test_templates/invalid_template.mjml.eex", 153 | mode: :yolo 154 | end 155 | end 156 | end 157 | 158 | test "should raise an error if the layout option is invalid" do 159 | assert_raise ArgumentError, ~r/could not load module InvalidModule due to reason/, fn -> 160 | defmodule InvalidLayoutOption do 161 | use MjmlEEx, 162 | mjml_template: "test_templates/invalid_template.mjml.eex", 163 | layout: InvalidModule 164 | end 165 | end 166 | end 167 | end 168 | 169 | describe "The use macro" do 170 | test "should fail to compile since a valid mjml template can not be found" do 171 | assert_raise RuntimeError, ~r/The provided :mjml_template does not exist at/, fn -> 172 | defmodule NoTemplateOption do 173 | use MjmlEEx 174 | end 175 | end 176 | end 177 | 178 | test "should fail to compile since the :mjml_template option points to a non-existent file" do 179 | assert_raise RuntimeError, ~r/The provided :mjml_template does not exist at/, fn -> 180 | defmodule NotFoundTemplateOption do 181 | use MjmlEEx, 182 | mjml_template: "does_not_exist.mjml.eex", 183 | mode: :compile 184 | end 185 | end 186 | end 187 | end 188 | 189 | describe "ComponentTemplate.render/1" do 190 | test "should render the document with the head and attribute block" do 191 | assert ComponentTemplate.render(all_caps: true) =~ "SIGN UP TODAY!!" 192 | assert ComponentTemplate.render(all_caps: true) =~ "Montserrat, Helvetica, Arial, sans-serif" 193 | end 194 | end 195 | 196 | describe "DynamicComponentTemplate.render/1" do 197 | test "should render the document with the appropriate assigns" do 198 | rendered_template = DynamicComponentTemplate.render(some_data: 1..5) 199 | 200 | assert rendered_template =~ "Some data - 1" 201 | assert rendered_template =~ "Some data - 2" 202 | assert rendered_template =~ "Some data - 3" 203 | assert rendered_template =~ "Some data - 4" 204 | assert rendered_template =~ "Some data - 5" 205 | end 206 | 207 | test "should emit a telemetry event when the rendering starts and completes" do 208 | # Attach the provided debug logger 209 | Telemetry.attach_logger(level: :info) 210 | 211 | # Attach custom handler 212 | :telemetry.attach_many( 213 | "mjml_eex_test_telemetry", 214 | [ 215 | [:mjml_eex, :render, :start], 216 | [:mjml_eex, :render, :stop] 217 | ], 218 | &__MODULE__.handle_telemetry/4, 219 | nil 220 | ) 221 | 222 | assert capture_log(fn -> 223 | DynamicComponentTemplate.render(some_data: 1..5) 224 | end) =~ "Measurements:" 225 | 226 | # Check the start event 227 | assert_received %{event: [:mjml_eex, :render, :start], measurements: measurements, metadata: metadata} 228 | assert Map.has_key?(measurements, :system_time) 229 | 230 | Enum.each([:compiler, :mode, :assigns, :mjml_template, :mjml_template_file, :layout_module], fn key -> 231 | assert Map.has_key?(metadata, key) 232 | end) 233 | 234 | # Check the stop event 235 | assert_received %{event: [:mjml_eex, :render, :stop], measurements: measurements, metadata: metadata} 236 | assert Map.has_key?(measurements, :duration) 237 | 238 | Enum.each( 239 | [:compiler, :mode, :assigns, :mjml_template, :mjml_template_file, :layout_module, :rendered_html], 240 | fn key -> 241 | assert Map.has_key?(metadata, key) 242 | end 243 | ) 244 | after 245 | Telemetry.detach_logger() 246 | :telemetry.detach("mjml_eex_test_telemetry") 247 | end 248 | end 249 | 250 | describe "CompileTimeDynamicComponentTemplate.render/1" do 251 | test "should raise an error if a dynamic component is rendered at compile time" do 252 | assert_raise RuntimeError, 253 | ~r/render_dynamic_component can only be used with runtime generated templates. Switch your template to `mode: :runtime`/, 254 | fn -> 255 | defmodule CompileTimeDynamicComponentTemplate do 256 | use MjmlEEx, 257 | mjml_template: "test_templates/dynamic_component_template.mjml.eex", 258 | mode: :compile 259 | end 260 | end 261 | end 262 | end 263 | 264 | describe "InvalidDynamicComponentTemplate.render/1" do 265 | test "should raise an error as dynamic components cannot render other dynamic components" do 266 | assert_raise RuntimeError, 267 | ~r/Cannot call `render_dynamic_component` inside of another dynamically rendered component/, 268 | fn -> 269 | InvalidDynamicComponentTemplate.render(some_data: 1..5) 270 | end 271 | end 272 | end 273 | 274 | describe "BadExpressionDynamicComponentTemplate" do 275 | test "should fail to compile since the render_dynamic_component call is not in an = expression" do 276 | assert_raise RuntimeError, 277 | ~r/render_dynamic_component can only be invoked inside of an <%= ... %> expression/, 278 | fn -> 279 | defmodule BadExpressionDynamicComponentTemplate do 280 | use MjmlEEx, 281 | mjml_template: "test_templates/bad_expression_dynamic_component_template.mjml.eex", 282 | mode: :runtime 283 | end 284 | end 285 | end 286 | end 287 | 288 | describe "InvalidComponentTemplate" do 289 | test "should fail to compile since the render_static_component call is not in an = expression" do 290 | assert_raise RuntimeError, 291 | ~r/render_static_component can only be invoked inside of an <%= ... %> expression/, 292 | fn -> 293 | defmodule InvalidTemplateOption do 294 | use MjmlEEx, 295 | mjml_template: "test_templates/invalid_component_template.mjml.eex", 296 | mode: :compile 297 | end 298 | end 299 | end 300 | end 301 | 302 | describe "LayoutTemplate.render/1" do 303 | test "should raise an error if no assigns are provided" do 304 | assert_raise ArgumentError, ~r/assign @call_to_action_text not available in template/, fn -> 305 | LayoutTemplate.render([]) 306 | end 307 | end 308 | 309 | test "should render the template using a layout" do 310 | assert LayoutTemplate.render(call_to_action_text: "Click me please!") =~ "Click me please!" 311 | end 312 | 313 | test "should escape scripts that are attempted to be added to the template" do 314 | assert LayoutTemplate.render(call_to_action_text: "Click me please!") =~ 315 | "<script>alert('Hacked!');</script>Click me please!" 316 | end 317 | end 318 | 319 | describe "AssignsTemplate.render/1" do 320 | test "should raise an error if no assigns are provided" do 321 | assert_raise ArgumentError, ~r/assign @padding not available in template/, fn -> 322 | AssignsLayoutTemplate.render([]) 323 | end 324 | end 325 | 326 | test "should render the template using a layout" do 327 | assert AssignsLayoutTemplate.render(call_to_action_text: "Click me please!", padding: "0px") =~ "Click me please!" 328 | end 329 | end 330 | 331 | describe "InvalidLayout" do 332 | test "should fail to compile since the layout contains no @inner_content expressions" do 333 | assert_raise RuntimeError, ~r/The provided :mjml_layout must contain one <%= @inner_content %> expression./, fn -> 334 | defmodule InvalidLayout do 335 | use MjmlEEx.Layout, 336 | mjml_layout: "test_layouts/invalid_layout.mjml.eex", 337 | mode: :compile 338 | end 339 | end 340 | end 341 | end 342 | 343 | describe "OtherInvalidLayout" do 344 | test "should fail to compile since the layout contains 2 @inner_content expressions" do 345 | assert_raise RuntimeError, 346 | ~r/The provided :mjml_layout contains multiple <%= @inner_content %> expressions./, 347 | fn -> 348 | defmodule OtherInvalidLayout do 349 | use MjmlEEx.Layout, 350 | mjml_layout: "test_layouts/other_invalid_layout.mjml.eex", 351 | mode: :compile 352 | end 353 | end 354 | end 355 | end 356 | 357 | describe "MissingOptionLayout" do 358 | test "should fail to compile since the use statement is missing a required option" do 359 | assert_raise RuntimeError, ~r/The :mjml_layout option is required./, fn -> 360 | defmodule MissingOptionLayout do 361 | use MjmlEEx.Layout 362 | end 363 | end 364 | end 365 | end 366 | 367 | describe "MissingFileLayout" do 368 | test "should fail to compile since the use statement is missing a required option" do 369 | assert_raise RuntimeError, ~r/The provided :mjml_layout does not exist at/, fn -> 370 | defmodule MissingFileLayout do 371 | use MjmlEEx.Layout, 372 | mode: :compile, 373 | mjml_layout: "invalid/path/to/layout.mjml.eex" 374 | end 375 | end 376 | end 377 | end 378 | end 379 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------