defmodule Jinja do @moduledoc """ Jinja is a fast, expressive, extensible templating engine written in Python. This library provides a public API for working with Jinja templates in Elixir. This is not a port of Jinja, but rather a wrapper that runs using `Pythonx`. ## Usage Add `Jinja` to your application supervision tree: children = [ Jinja, ... ] ## Loaders The default loader is `:dict`. This allows you to register templates at runtime, for the lifetime of your application. Templates can be loaded and rendered as such: Jinja.load_template("hello", "hewwo {{ name }}") # => :ok Jinja.render_template("hello", %{name: "Robin"}) # => {:ok, "hewwo Robin"} The `:path` loader allows you to specify a directory on disk to load templates from. When configured, the `load_template/2` function will be unavailable. children = [ {Jinjq, loader: :path, from: Application.app_dir(:your_app, ~w(lib your_app_web templates)) } ] # Loads template from lib/your_app_web/templates/hello.html Jinja.render_template("hello.html", %{name: "Robin"}) # => {:ok, "hewwo Robin"} # `load_template/2` is unavailable for loader: :path Jinja.load_template("bye", "...") # => {:error, "loading templates at runtime is only supported for loader: :dict"} """ use GenServer defstruct [:loader, :globals] import Structo @doc false def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Renders a template string with given assigns. iex> Jinja.render_string("

hewwo {{ name }}

", %{"name" => "world"}) {:ok, "

hewwo world

"} """ @spec render_string(String.t(), map()) :: {:ok, String.t()} | {:error, term()} def render_string(template, assigns \\ %{}) when is_binary(template) and is_map(assigns) do GenServer.call(__MODULE__, {:render_string, template, assigns}) end @doc """ Loads a template with the given name and source. iex> Jinja.load_template("page", \""" {% block body %}{% endblock %} \""") :ok iex> Jinja.load_template("post", \""" {% extends "page" %} {% block body %} {{ title }} {% endblock %} \""") :ok """ @spec load_template(String.t(), String.t()) :: :ok | {:error, term()} def load_template(name, source) when is_binary(name) and is_binary(source) do GenServer.call(__MODULE__, {:load_template, name, source}) end @doc """ Renders a previously loaded template with given assigns. iex> Jinja.render_template("post", %{title: "hewwo world"}) {:ok, "hewwo world"} """ @spec load_template(String.t(), map()) :: {:ok, String.t()} | {:error, term()} def render_template(name, assigns \\ %{}) when is_binary(name) and is_map(assigns) do GenServer.call(__MODULE__, {:render_template, name, assigns}) end @doc false def init(opts) do ensure_python_initialized() {loader, globals} = init_state(opts) {:ok, ~m{:__MODULE__, loader, globals}} end defp ensure_python_initialized do if toml = Application.get_env(:pythonx, :pyproject_toml) do if String.contains?(toml, "Jinja2") do :ok else raise "Please add Jinja2 to your :pyproject_toml config for Pythonx" end else try do Pythonx.eval("1 + 1", %{}) :ok rescue _ -> Pythonx.uv_init(""" [project] name = "jinja-elixir" version = "#{Application.spec(:jinja, :vsn)}" requires-python = "==3.13.*" dependencies = [ "Jinja2==3.1.6" ] """) end end end defp init_state(opts) do case Keyword.get(opts, :loader, :dict) do :dict -> {:dict, init_dict_loader()} :path -> {:path, init_path_loader(opts)} end rescue e -> raise "Failed to initialize Jinja. Make sure Pythonx is configured with the Jinja2 dependency. Got: #{inspect(e)}" end defp init_dict_loader do initialise(""" from jinja2 import Environment, DictLoader, select_autoescape templates = {} loader = DictLoader(templates) env = Environment( loader=loader, autoescape=select_autoescape(['html', 'htm', 'xml']) ) """) end defp init_path_loader(opts) do search_path = Keyword.get(opts, :from) || raise "when using loader: :path, please provide the search path via the :from option" initialise(""" from jinja2 import Environment, FileSystemLoader, select_autoescape env = Environment( loader=FileSystemLoader('#{search_path}'), autoescape=select_autoescape(['html', 'htm', 'xml']) ) """) end def handle_call({:render_string, template, assigns}, _from, state) do globals = state.globals |> put_glob(:source, template) |> put_glob(:assigns, assigns) rendered = execute(globals, """ env.from_string(source).render(assigns) """) {:reply, {:ok, rendered}, state} rescue error -> {:reply, {:error, error}, state} end def handle_call({:load_template, name, source}, _from, %{loader: :dict} = state) do globals = state.globals |> put_glob(:name, name) |> put_glob(:source, source) execute(globals, """ templates[name] = source env.loader = DictLoader(templates) True """) {:reply, :ok, state} rescue error -> {:reply, {:error, error}, state} end def handle_call({:load_template, _name, _source}, _from, %{loader: :path} = state) do {:reply, {:error, "loading templates at runtime is only supported for loader: :dict"}, state} end def handle_call({:render_template, name, assigns}, _from, state) do globals = state.globals |> put_glob(:name, name) |> put_glob(:assigns, assigns) rendered = execute(globals, "env.get_template(name).render(assigns)") {:reply, {:ok, rendered}, state} rescue error -> {:reply, {:error, error}, state} end defp initialise(source) do source |> Pythonx.eval(%{}) |> then(fn {_, g} -> g end) end defp execute(globals, source) do source |> Pythonx.eval(globals) |> then(fn {r, _} -> r end) |> Pythonx.decode() end defp put_glob(globals, name, value) do Map.put(globals, to_string(name), Pythonx.encode!(value)) end end Code.compiler_options(ignore_module_conflict: true) defimpl Pythonx.Encoder, for: BitString do def encode(string, _opts) do Pythonx.NIF.unicode_from_string(string) end end