Skip to content

Commit

Permalink
Merge pull request #7 from akoutmos/dynamic_components
Browse files Browse the repository at this point in the history
Adding dynamic components
  • Loading branch information
akoutmos authored May 7, 2022
2 parents 17c8606 + 4b0ed4a commit 7b04c25
Show file tree
Hide file tree
Showing 16 changed files with 465 additions and 57 deletions.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.0] - 2021-05-06

### Added

- The `render_static_component` function can be used to render components that don't make use of any assigns. For
example, in your template you would have: `<%= render_static_component MyCoolComponent, static: "data" %>` and this
can be rendered at compile time as well as runtime.
- The `render_dynamic_component` function can be used to render components that make use of assigns at runtime. For
example, in your template you would have: `<%= render_dynamic_component MyCoolComponent, static: @data %>`.

### Changed

- When calling `use MjmlEEx`, if the `:mjml_template` option is not provided, the module attempts to find a template
file in the same directory that has the same file name as the module (with the `.mjml.eex` extension instead
of `.ex`). This functions similar to how Phoenix and LiveView handle their templates.

### Removed

- `render_component` is no longer available and users should now use `render_static_component` or
`render_dynamic_component`.

## [0.5.0] - 2021-04-28

### Added
Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ dependencies in `mix.exs`:
```elixir
def deps do
[
{:mjml_eex, "~> 0.5.0"}
{:mjml_eex, "~> 0.6.0"}
]
end
```
Expand Down Expand Up @@ -78,7 +78,7 @@ Checkout my [GitHub Sponsorship page](https://github.com/sponsors/akoutmos) if y

### Basic Usage

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

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

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

```elixir
Expand Down Expand Up @@ -170,7 +170,7 @@ And the following template:

```html
<mjml>
<%= render_component HeadBlock %>
<%= render_static_component HeadBlock %>

<mj-body>
<mj-section>
Expand All @@ -183,8 +183,25 @@ And the following template:
</mjml>
```

Be sure to look at the `MjmlEEx.Component` for additional usage information as you can also pass options
to your template and use them when generating the partial string.
Be sure to look at the `MjmlEEx.Component` module for additional usage information as you can also pass options to your
template and use them when generating the partial string. One thing to note is that when using
`render_static_component`, the data that is passed to the component must be defined at compile time. This means that you
cannot use any assigns that would bee to be evaluated at runtime. For example, this would raise an error:

```elixir
<mj-text>
<%= render_static_component MyTextComponent, some_data: @some_data %>
</mj-text>
```

If you need to render your components dynamically, use `render_dynamic_component` instead and be sure to configure your
template module like so to generate the email HTML at runtime:

```elixir
def MyTemplate do
use MjmlEEx, mode: :runtime
end
```

### Using Layouts

Expand Down
60 changes: 51 additions & 9 deletions lib/engines/mjml.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ defmodule MjmlEEx.Engines.Mjml do
@impl true
def init(opts) do
{caller, remaining_opts} = Keyword.pop!(opts, :caller)
{mode, remaining_opts} = Keyword.pop!(remaining_opts, :mode)
{rendering_dynamic_component, remaining_opts} = Keyword.pop(remaining_opts, :rendering_dynamic_component, false)

remaining_opts
|> EEx.Engine.init()
|> Map.put(:caller, caller)
|> Map.put(:mode, mode)
|> Map.put(:rendering_dynamic_component, rendering_dynamic_component)
end

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

@impl true
def handle_expr(state, "=", {:render_component, _, [{:__aliases__, _, _module} = aliases]}) do
def handle_expr(%{mode: :compile}, _marker, {:render_dynamic_component, _, _}) do
raise "render_dynamic_component can only be used with runtime generated templates. Switch your template to `mode: :runtime`"
end

def handle_expr(%{rendering_dynamic_component: true}, _marker, {:render_dynamic_component, _, _}) do
raise "Cannot call `render_dynamic_component` inside of another dynamically rendered component"
end

def handle_expr(state, "=", {:render_dynamic_component, _, [{:__aliases__, _, _module} = aliases]}) do
module = Macro.expand(aliases, state.caller)

do_render_dynamic_component(state, module, [])
end

def handle_expr(state, "=", {:render_dynamic_component, _, [{:__aliases__, _, _module} = aliases, opts]}) do
module = Macro.expand(aliases, state.caller)

do_render_dynamic_component(state, module, opts)
end

def handle_expr(_state, _marker, {:render_dynamic_component, _, _}) do
raise "render_dynamic_component can only be invoked inside of an <%= ... %> expression"
end

def handle_expr(state, "=", {:render_static_component, _, [{:__aliases__, _, _module} = aliases]}) do
module = Macro.expand(aliases, state.caller)

do_render_component(state, module, [], state.caller)
do_render_static_component(state, module, [])
end

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

do_render_component(state, module, opts, state.caller)
do_render_static_component(state, module, opts)
end

def handle_expr(_state, _marker, {:render_component, _, _}) do
raise "render_component can only be invoked inside of an <%= ... %> expression"
def handle_expr(_state, _marker, {:render_static_component, _, _}) do
raise "render_static_component can only be invoked inside of an <%= ... %> expression"
end

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

defp do_render_component(state, module, opts, caller) do
defp do_render_static_component(state, module, opts) do
{mjml_component, _} =
module
|> apply(:render, [opts])
|> Utils.escape_eex_expressions()
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller)
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: state.caller, mode: state.mode)
|> Code.eval_quoted()

%{binary: binary} = state
%{state | binary: [mjml_component | binary]}
end

defp do_render_dynamic_component(state, module, opts) do
caller =
state
|> Map.get(:caller)
|> :erlang.term_to_binary()
|> Base.encode64()

mjml_component =
"<%= Phoenix.HTML.raw(MjmlEEx.Utils.render_dynamic_component(#{module}, #{Macro.to_string(opts)}, \"#{caller}\")) %>"

%{binary: binary} = state
%{state | binary: [mjml_component | binary]}
end
end
59 changes: 38 additions & 21 deletions lib/mjml_eex.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
defmodule MjmlEEx do
@moduledoc """
Documentation for `MjmlEEx` template module. This moule contains the macro
that is used to create an MJML EEx template.
that is used to create an MJML EEx template. The macro can be configured to
render the MJML template in a few different ways, so be sure to read the
option documentation.
## Macro Options
- `:mjml_template`- A binary that specifies the name of the `.mjml.eex` template that the module will compile. The
directory path is relative to the template module. If this option is not provided, the MjmlEEx will look for a
file that has the same name as the module but with the `.mjml.ex` extension as opposed to `.ex`.
- `:mode`- This option defines when the MJML template is actually compiled. The possible values are `:runtime` and
`:compile`. When this option is set to `:compile`, the MJML template is compiled into email compatible HTML at
compile time. It is suggested that this mode is only used if the template is relatively simple and there are only
assigns being used as text or attributes on html elements (as opposed to attributes on MJML elements). The reason
for that being that these assigns may be discarded as part of the MJML compilation phase. On the plus side, you
do get a performance bump here since the HTML for the email is already generated. When this is set to `:runtime`,
the MJML template is compiled at runtime and all the template assigns are applied prior to the MJML compilation
phase. These means that there is a performance hit since you are compiling the MJML template every time, but the
template can use more complex EEx constructs like `for`, `case` and `cond`. The default configuration is `:runtime`.
- `:layout` - This option defines what layout the template should be injected into prior to rendering the template.
This is useful if you want to have reusable email templates in order to keep your email code DRY and reusable.
Your template will then be injected into the layout where the layout defines `<%= inner_content %>`.
## Example Usage
You can use this module like so:
Expand Down Expand Up @@ -37,18 +61,14 @@ defmodule MjmlEEx do
alias MjmlEEx.Utils

defmacro __using__(opts) do
mjml_template =
case Keyword.fetch(opts, :mjml_template) do
{:ok, mjml_template} ->
%Macro.Env{file: calling_module_file} = __CALLER__

calling_module_file
|> Path.dirname()
|> Path.join(mjml_template)
# Get some data about the calling module
%Macro.Env{file: calling_module_file} = __CALLER__
module_directory = Path.dirname(calling_module_file)
file_minus_extension = Path.basename(calling_module_file, ".ex")
mjml_template_file = Keyword.get(opts, :mjml_template, "#{file_minus_extension}.mjml.eex")

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

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

module when is_atom(module) ->
get_raw_template_with_layout(mjml_template, layout_module, __CALLER__)

invalid_layout ->
raise "#{inspect(invalid_layout)} is an invalid layout option"
get_raw_template_with_layout(mjml_template, layout_module, compilation_mode, __CALLER__)
end

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

defp get_raw_template(template_path, caller) do
defp get_raw_template(template_path, mode, caller) do
{mjml_document, _} =
template_path
|> File.read!()
|> Utils.escape_eex_expressions()
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller)
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller, mode: mode)
|> Code.eval_quoted()

Utils.decode_eex_expressions(mjml_document)
end

defp get_raw_template_with_layout(template_path, layout_module, caller) do
defp get_raw_template_with_layout(template_path, layout_module, mode, caller) do
template_file_contents = File.read!(template_path)
pre_inner_content = layout_module.pre_inner_content()
post_inner_content = layout_module.post_inner_content()
Expand All @@ -179,7 +196,7 @@ defmodule MjmlEEx do
[pre_inner_content, template_file_contents, post_inner_content]
|> Enum.join()
|> Utils.escape_eex_expressions()
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller)
|> EEx.compile_string(engine: MjmlEEx.Engines.Mjml, line: 1, trim: true, caller: caller, mode: mode)
|> Code.eval_quoted()

Utils.decode_eex_expressions(mjml_document)
Expand Down
20 changes: 14 additions & 6 deletions lib/mjml_eex/component.ex
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
defmodule MjmlEEx.Component do
@moduledoc """
This module allows you to define a reusable MJML component that
can be injected into an MJML template prior to it being
rendered into HTML. To do so, create an `MjmlEEx.Component`
module that looks like so:
This module allows you to define a reusable MJML component that can be injected into
an MJML template prior to it being rendered into HTML. There are two different ways
that components can be rendered in templates. The first being `render_static_component`
and the other being `render_dynamic_component`. `render_static_component` should be used
to render the component when the data provided to the component is known at compile time.
If you want to dynamically render a component (make sure that the template is set to
`mode: :runtime`) with assigns that are passed to the template, then use
`render_dynamic_component`.
## Example Usage
To use an MjmlEEx component, create an `MjmlEEx.Component` module that looks like so:
```elixir
defmodule HeadBlock do
Expand All @@ -22,7 +30,7 @@ defmodule MjmlEEx.Component do
```
With that in place, anywhere that you would like to use the component, you can add:
`<%= render_component HeadBlock %>` in your MJML EEx template.
`<%= render_static_component HeadBlock %>` in your MJML EEx template.
You can also pass options to the render function like so:
Expand All @@ -42,7 +50,7 @@ defmodule MjmlEEx.Component do
end
```
And calling it like so: `<%= render_component(HeadBlock, title: "Some really cool title") %>`
And calling it like so: `<%= render_static_component(HeadBlock, title: "Some really cool title") %>`
"""

@doc """
Expand Down
27 changes: 26 additions & 1 deletion lib/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ defmodule MjmlEEx.Utils do
Elixir expressions in MJML EEx templates.
"""

@mjml_eex_special_expressions [:render_static_component, :render_dynamic_component]

@doc """
This function encodes the internals of an MJML EEx document
so that when it is compiled, the EEx expressions don't break
Expand Down Expand Up @@ -51,6 +53,29 @@ defmodule MjmlEEx.Utils do
end
end

@doc false
def render_dynamic_component(module, opts, caller) do
caller =
caller
|> Base.decode64!()
|> :erlang.binary_to_term()

{mjml_component, _} =
module
|> apply(:render, [opts])
|> EEx.compile_string(
engine: MjmlEEx.Engines.Mjml,
line: 1,
trim: true,
caller: caller,
mode: :runtime,
rendering_dynamic_component: true
)
|> Code.eval_quoted()

mjml_component
end

defp reduce_tokens(tokens) do
tokens
|> Enum.reduce("", fn
Expand All @@ -65,7 +90,7 @@ defmodule MjmlEEx.Utils do
|> Code.string_to_quoted()

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

_ ->
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule MjmlEEx.MixProject do
def project do
[
app: :mjml_eex,
version: "0.5.0",
version: "0.6.0",
elixir: ">= 1.11.0",
elixirc_paths: elixirc_paths(Mix.env()),
name: "MJML EEx",
Expand Down
Loading

0 comments on commit 7b04c25

Please sign in to comment.