Implementing Phoenix-like EEx functionality from scratch

Hello!

I’ve been working on a little news aggregator that uses EEx templates to render pages, and I’ve found myself reconstructing a lot of Phoenix’s functionality (I’m not actually using Phoenix, just Plug/Cowboy), but haven’t quite found an elegant way to deal with compiling my templates so that I don’t have to read from eex files in priv/ at runtime.

At the moment, I have a base Template module with a __using__ macro that injects the following render/3 function into my “views”. The function calls back out to the Template module, passing in information regarding the caller module, the file we want to render, etc:

In Template:

defmacro __using__(opts) do
    layout = Keyword.get(opts, :layout, "base.html.eex") |> (&(Path.join(@layout_dir, &1))).()
    quote do
        def render(conn, file, assigns \\ []), do: Template.render(__MODULE__, conn, file, assigns)

        def layout, do: unquote(layout)
    end
end

def render(module, %{ status: status } = conn, file, assigns) do
	rendered_template = render_template(module, file, assigns)
	rendered_layout = render_layout(module, [ assigns | [ inner_content: rendered_template ] ])

	Plug.Conn.send_resp(conn, (status || 200), rendered_layout)
end 

# Both render_layout and render_function are ultimately calls to this next function, render_file

defp render_file(file, assigns, eval_options \\ []) do
	quoted = EEx.compile_file(file)
	# Note that binding() will return, among others, the "assigns" parameter, allowing us to use the @key EEx convenience in templates.
	{result, _binding} = Code.eval_quoted(quoted, binding(), eval_options)
	result
end

Of course, this means that I’m constantly compiling and rendering files at runtime, when I could be precompiling them. In trying to figure out how Phoenix handles this, I found the following function in Phoenix.Template that seems to be at the heart of how templates are compiled in the framework:

defp compile(path, root, engines) do
	name   = template_path_to_name(path, root)
	defp   = String.to_atom(name)
	ext    = Path.extname(path) |> String.trim_leading(".") |> String.to_atom
	engine = Map.fetch!(engines, ext)
	quoted = engine.compile(path, name)

	{name, quote do
	  @file unquote(path)
	  @external_resource unquote(path)

	  defp unquote(defp)(var!(assigns)) do
	    _ = var!(assigns)
	    unquote(quoted)
	  end

	  defp render_template(unquote(name), assigns) do
	    unquote(defp)(assigns)
	  end
	end}
end

This gets called from a defmacro __before_compile__(env) declaration, which in turn unquotes the newly minted functions in whatever view we’re building. I want to clear up my understanding of how this works before running off and trying to re-implement this on my own:

  1. From what I gather, all .eex template files, regardless of whether or not they’re actually “used”, are compiled according to the namespace of their parent view? It’s my understanding that these get turned into named functions that correspond to their file names?

  2. When calling render(conn, <filename>) in a controller, we’re not actually saying “hey, find this file and render it”, rather what we’re doing is calling a function named according to <filename>? This function is the already-compiled EEx template injected into the corresponding view?

  3. Output from an EEx.compile_file/2 call will generate a “quoted” structure - to evaluate said structure, is it enough to simply unquote it within a function, and ensure that an assigns binding is present? At present I’ve been evaluating it with Code.eval_quoted, but it seems that the Phoenix implementation doesn’t use this?

  4. I’ve been incorporating the caller view-module’s “context” (i.e. functions declared in the “view” module) that I want available in the EEx template by passing in functions: [{<caller module>, <caller module>.__info__(:functions)}] into the Code.eval_quoted call within the parent Template module. Is this made redundant if I’m injecting a function into the view that contains the unquoted result of EEx.compile_file? In other words, will the now-unquoted structure generated by compile_file have access to the surrounding module’s functions?

It’s been hard developing an intuitive understanding of how this works behind the scenes - it’s so seamless when you’re using these constructs as they’re provided! Any help clarifying these points would be much appreciated.

1 Like

Sorry if this sounds unhelpful, but can’t you just incorporate the Phoenix.View since it’s been pulled out of Phoenix?

I think that nimble_publisher might be interesting for your use case.

2 Likes

A bit late - thank you for the nimble_publisher recommendation!

Not unhelpful, I had just found it kind of difficult to wrap my mind around what Phoenix was actually doing at the View <—> Template layer, and was trying to better understand it by recreating the functionality (in a much more rudimentary way of course). I ended up learning a lot about precompiling vs compiling on the fly, and the role of macros in the whole equation.

For my own little proof of concept with LiveView single-file components I do the following:

  • read file contents
  • generate module source. In this case I’m literally building a string of the source, but it can be done with creative uses of quote do (though I don’t have an example at the moment)
  • call Code.compile_quoted

This way you can compile everything on start (provided you start your compiler beforehand)


Another option (if you don’t want to monitor files and only compile them on startup) is to create a mix compiler task and add it to compiler list. Mix task, and config

1 Like

I ended up doing a similar thing, pre-compiling everything via macros. It’s essentially a naive re-implementation of Phoenix’s way of doing things (in that it relies on “views” that hold the newly-compiled “templates” as functions), but as an exercise it was great in that now I feel I understand what’s going on. Here’s a gist of it, in case anyone finds it helpful.

1 Like