├── 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 |
3 |
4 |
5 |
6 | Easily create beautiful emails using MJML right from Elixir!
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
64 |
65 |
66 | ### Silver Sponsors
67 |
68 |
69 |
70 |
71 |
72 | ### Bronze Sponsors
73 |
74 |
75 |
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 |
--------------------------------------------------------------------------------