Skip to content

Commit 7b04c25

Browse files
authored
Merge pull request #7 from akoutmos/dynamic_components
Adding dynamic components
2 parents 17c8606 + 4b0ed4a commit 7b04c25

16 files changed

+465
-57
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.0] - 2021-05-06
11+
12+
### Added
13+
14+
- The `render_static_component` function can be used to render components that don't make use of any assigns. For
15+
example, in your template you would have: `<%= render_static_component MyCoolComponent, static: "data" %>` and this
16+
can be rendered at compile time as well as runtime.
17+
- The `render_dynamic_component` function can be used to render components that make use of assigns at runtime. For
18+
example, in your template you would have: `<%= render_dynamic_component MyCoolComponent, static: @data %>`.
19+
20+
### Changed
21+
22+
- When calling `use MjmlEEx`, if the `:mjml_template` option is not provided, the module attempts to find a template
23+
file in the same directory that has the same file name as the module (with the `.mjml.eex` extension instead
24+
of `.ex`). This functions similar to how Phoenix and LiveView handle their templates.
25+
26+
### Removed
27+
28+
- `render_component` is no longer available and users should now use `render_static_component` or
29+
`render_dynamic_component`.
30+
1031
## [0.5.0] - 2021-04-28
1132

1233
### Added

README.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ dependencies in `mix.exs`:
4242
```elixir
4343
def deps do
4444
[
45-
{:mjml_eex, "~> 0.5.0"}
45+
{:mjml_eex, "~> 0.6.0"}
4646
]
4747
end
4848
```
@@ -78,7 +78,7 @@ Checkout my [GitHub Sponsorship page](https://github.com/sponsors/akoutmos) if y
7878

7979
### Basic Usage
8080

81-
Add `{:mjml_eex, "~> 0.5.0"}` to your `mix.exs` file and run `mix deps.get`. After you have that in place, you
81+
Add `{:mjml_eex, "~> 0.6.0"}` to your `mix.exs` file and run `mix deps.get`. After you have that in place, you
8282
can go ahead and create a template module like so:
8383

8484
```elixir
@@ -141,7 +141,7 @@ In order to render the email you would then call: `FunctionTemplate.render(first
141141
### Using Components
142142

143143
In addition to compiling single MJML EEx templates, you can also create MJML partials and include them
144-
in other MJML templates AND components using the special `render_component` function. With the following
144+
in other MJML templates AND components using the special `render_static_component` function. With the following
145145
modules:
146146

147147
```elixir
@@ -170,7 +170,7 @@ And the following template:
170170

171171
```html
172172
<mjml>
173-
<%= render_component HeadBlock %>
173+
<%= render_static_component HeadBlock %>
174174

175175
<mj-body>
176176
<mj-section>
@@ -183,8 +183,25 @@ And the following template:
183183
</mjml>
184184
```
185185

186-
Be sure to look at the `MjmlEEx.Component` for additional usage information as you can also pass options
187-
to your template and use them when generating the partial string.
186+
Be sure to look at the `MjmlEEx.Component` module for additional usage information as you can also pass options to your
187+
template and use them when generating the partial string. One thing to note is that when using
188+
`render_static_component`, the data that is passed to the component must be defined at compile time. This means that you
189+
cannot use any assigns that would bee to be evaluated at runtime. For example, this would raise an error:
190+
191+
```elixir
192+
<mj-text>
193+
<%= render_static_component MyTextComponent, some_data: @some_data %>
194+
</mj-text>
195+
```
196+
197+
If you need to render your components dynamically, use `render_dynamic_component` instead and be sure to configure your
198+
template module like so to generate the email HTML at runtime:
199+
200+
```elixir
201+
def MyTemplate do
202+
use MjmlEEx, mode: :runtime
203+
end
204+
```
188205

189206
### Using Layouts
190207

lib/engines/mjml.ex

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ defmodule MjmlEEx.Engines.Mjml do
1010
@impl true
1111
def init(opts) do
1212
{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)
1315

1416
remaining_opts
1517
|> EEx.Engine.init()
1618
|> Map.put(:caller, caller)
19+
|> Map.put(:mode, mode)
20+
|> Map.put(:rendering_dynamic_component, rendering_dynamic_component)
1721
end
1822

1923
@impl true
@@ -29,35 +33,73 @@ defmodule MjmlEEx.Engines.Mjml do
2933
defdelegate handle_text(state, meta, text), to: EEx.Engine
3034

3135
@impl true
32-
def handle_expr(state, "=", {:render_component, _, [{:__aliases__, _, _module} = aliases]}) do
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
3361
module = Macro.expand(aliases, state.caller)
3462

35-
do_render_component(state, module, [], state.caller)
63+
do_render_static_component(state, module, [])
3664
end
3765

38-
def handle_expr(state, "=", {:render_component, _, [{:__aliases__, _, _module} = aliases, opts]}) do
66+
def handle_expr(state, "=", {:render_static_component, _, [{:__aliases__, _, _module} = aliases, opts]}) do
3967
module = Macro.expand(aliases, state.caller)
4068

41-
do_render_component(state, module, opts, state.caller)
69+
do_render_static_component(state, module, opts)
4270
end
4371

44-
def handle_expr(_state, _marker, {:render_component, _, _}) do
45-
raise "render_component can only be invoked inside of an <%= ... %> expression"
72+
def handle_expr(_state, _marker, {:render_static_component, _, _}) do
73+
raise "render_static_component can only be invoked inside of an <%= ... %> expression"
4674
end
4775

4876
def handle_expr(_state, marker, expr) do
49-
raise "Unescaped expression. This should never happen and is most likely a bug in MJML EEx: <%#{marker} #{Macro.to_string(expr)} %>"
77+
raise "Invalid expression. Components can only have `render_static_component` and `render_dynamic_component` EEx expression: <%#{marker} #{Macro.to_string(expr)} %>"
5078
end
5179

52-
defp do_render_component(state, module, opts, caller) do
80+
defp do_render_static_component(state, module, opts) do
5381
{mjml_component, _} =
5482
module
5583
|> apply(:render, [opts])
5684
|> Utils.escape_eex_expressions()
57-
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller)
85+
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: state.caller, mode: state.mode)
5886
|> Code.eval_quoted()
5987

6088
%{binary: binary} = state
6189
%{state | binary: [mjml_component | binary]}
6290
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
63105
end

lib/mjml_eex.ex

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
11
defmodule MjmlEEx do
22
@moduledoc """
33
Documentation for `MjmlEEx` template module. This moule contains the macro
4-
that is used to create an MJML EEx template.
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
529
630
You can use this module like so:
731
@@ -37,18 +61,14 @@ defmodule MjmlEEx do
3761
alias MjmlEEx.Utils
3862

3963
defmacro __using__(opts) do
40-
mjml_template =
41-
case Keyword.fetch(opts, :mjml_template) do
42-
{:ok, mjml_template} ->
43-
%Macro.Env{file: calling_module_file} = __CALLER__
44-
45-
calling_module_file
46-
|> Path.dirname()
47-
|> Path.join(mjml_template)
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")
4869

49-
:error ->
50-
raise "The :mjml_template option is required."
51-
end
70+
# The absolute path of the mjml template
71+
mjml_template = Path.join(module_directory, mjml_template_file)
5272

5373
unless File.exists?(mjml_template) do
5474
raise "The provided :mjml_template does not exist at #{inspect(mjml_template)}."
@@ -65,13 +85,10 @@ defmodule MjmlEEx do
6585
raw_mjml_template =
6686
case layout_module do
6787
:none ->
68-
get_raw_template(mjml_template, __CALLER__)
88+
get_raw_template(mjml_template, compilation_mode, __CALLER__)
6989

7090
module when is_atom(module) ->
71-
get_raw_template_with_layout(mjml_template, layout_module, __CALLER__)
72-
73-
invalid_layout ->
74-
raise "#{inspect(invalid_layout)} is an invalid layout option"
91+
get_raw_template_with_layout(mjml_template, layout_module, compilation_mode, __CALLER__)
7592
end
7693

7794
generate_functions(compilation_mode, raw_mjml_template, mjml_template, layout_module)
@@ -159,18 +176,18 @@ defmodule MjmlEEx do
159176
raise "#{inspect(invalid_mode)} is an invalid :mode. Possible values are :runtime or :compile"
160177
end
161178

162-
defp get_raw_template(template_path, caller) do
179+
defp get_raw_template(template_path, mode, caller) do
163180
{mjml_document, _} =
164181
template_path
165182
|> File.read!()
166183
|> Utils.escape_eex_expressions()
167-
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller)
184+
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller, mode: mode)
168185
|> Code.eval_quoted()
169186

170187
Utils.decode_eex_expressions(mjml_document)
171188
end
172189

173-
defp get_raw_template_with_layout(template_path, layout_module, caller) do
190+
defp get_raw_template_with_layout(template_path, layout_module, mode, caller) do
174191
template_file_contents = File.read!(template_path)
175192
pre_inner_content = layout_module.pre_inner_content()
176193
post_inner_content = layout_module.post_inner_content()
@@ -179,7 +196,7 @@ defmodule MjmlEEx do
179196
[pre_inner_content, template_file_contents, post_inner_content]
180197
|> Enum.join()
181198
|> Utils.escape_eex_expressions()
182-
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller)
199+
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller, mode: mode)
183200
|> Code.eval_quoted()
184201

185202
Utils.decode_eex_expressions(mjml_document)

lib/mjml_eex/component.ex

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
defmodule MjmlEEx.Component do
22
@moduledoc """
3-
This module allows you to define a reusable MJML component that
4-
can be injected into an MJML template prior to it being
5-
rendered into HTML. To do so, create an `MjmlEEx.Component`
6-
module that looks like so:
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:
715
816
```elixir
917
defmodule HeadBlock do
@@ -22,7 +30,7 @@ defmodule MjmlEEx.Component do
2230
```
2331
2432
With that in place, anywhere that you would like to use the component, you can add:
25-
`<%= render_component HeadBlock %>` in your MJML EEx template.
33+
`<%= render_static_component HeadBlock %>` in your MJML EEx template.
2634
2735
You can also pass options to the render function like so:
2836
@@ -42,7 +50,7 @@ defmodule MjmlEEx.Component do
4250
end
4351
```
4452
45-
And calling it like so: `<%= render_component(HeadBlock, title: "Some really cool title") %>`
53+
And calling it like so: `<%= render_static_component(HeadBlock, title: "Some really cool title") %>`
4654
"""
4755

4856
@doc """

lib/utils.ex

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ defmodule MjmlEEx.Utils do
44
Elixir expressions in MJML EEx templates.
55
"""
66

7+
@mjml_eex_special_expressions [:render_static_component, :render_dynamic_component]
8+
79
@doc """
810
This function encodes the internals of an MJML EEx document
911
so that when it is compiled, the EEx expressions don't break
@@ -51,6 +53,29 @@ defmodule MjmlEEx.Utils do
5153
end
5254
end
5355

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+
5479
defp reduce_tokens(tokens) do
5580
tokens
5681
|> Enum.reduce("", fn
@@ -65,7 +90,7 @@ defmodule MjmlEEx.Utils do
6590
|> Code.string_to_quoted()
6691

6792
case captured_expression do
68-
{:ok, {:render_component, _line, _args}} ->
93+
{:ok, {special_expression, _line, _args}} when special_expression in @mjml_eex_special_expressions ->
6994
acc <> "<%#{normalize_marker(marker)} #{List.to_string(expression)} %>"
7095

7196
_ ->

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule MjmlEEx.MixProject do
44
def project do
55
[
66
app: :mjml_eex,
7-
version: "0.5.0",
7+
version: "0.6.0",
88
elixir: ">= 1.11.0",
99
elixirc_paths: elixirc_paths(Mix.env()),
1010
name: "MJML EEx",

0 commit comments

Comments
 (0)