defmodule Eml do alias Eml.Markup alias Eml.Template alias Eml.Readable @default_lang Eml.Language.Html @type element :: binary | Eml.Markup.t | Eml.Parameter.t | Eml.Template.t @type content :: [element] @type t :: element | content @type data :: Eml.Readable.t @type error :: { :error, term } @type lang :: atom @type path :: binary @type unpackr_result :: funpackr_result | [unpackr_result] @type funpackr_result :: binary | Eml.Parameter.t | Eml.Template.t | [binary | Eml.Parameter | Eml.Template.t] @doc """ Define eml content. Just like in other Elixir blocks, evaluates all expressions and returns the last. Code inside an eml block is just regular Elixir code. The purpose of the `eml/2` macro is to make it more convenient to write eml. It does this by doing two things: * Provide a lexical scope where al markup macro's are imported to * Read the last expression of the block in order to guarantee valid eml content To illustrate, the expressions below all produce the same output: * `eml do: div 42` * `Eml.Markup.new(tag: :div, content: 42) |> Eml.read!(Eml.Language.Native)` * `Eml.Markup.Html.div(%{}, 42) |> Eml.read!(Eml.Language.Native)` Note that since the Elixir `Kernel` module by default imports the `div/2` macro in to the global namespace, this macro is inside an eml block only available as `Kernel.div/2` """ defmacro eml(opts \\ [], do_block) do opts = Keyword.merge(opts, do_block) lang = opts[:use] || @default_lang expr = opts[:do] quote do (fn -> use unquote(lang) Eml.read! unquote(expr), Eml.Language.Native end).() end end @doc """ Define a function that produces eml. This macro is provided both for convenience and to be able to show intention of code. This: `defmarkup mydiv(content), do: div(%{}, content)` is effectively the same as: `def mydiv(content), do: eml do div(%{}, content) end` """ defmacro defmarkup(call, do_block) do markup = do_block[:use] || @default_lang expr = do_block[:do] quote do def unquote(call) do use unquote(markup) Eml.read! unquote(expr), Eml.Language.Native end end end @doc """ Selects content from arbritary eml. It will traverse the complete eml tree, so all elements are evaluated. There is however currently no way to select templates or parameters. Content is matched depending on the provided options. Those options can be: * `:tag` - match markup content by tag (`atom`) * `:id` - match markup content by id (`binary`) * `:class` - match markup content by class (`binary`) * `:pat` - match binary content by regular expression (`RegEx.t`) * `:parent` - when set to true, selects the parent element of the matched content (`boolean`) When `:tag`, `:id`, or `:class` are combined, only markup is selected that satisfies all conditions. When the `:pat` options is used, `:tag`, `:id` and `:class` will be ignored. ## Examples: iex> e = eml do ...> div do ...> span %{id: "inner1", class: "inner"}, "hello " ...> span %{id: "inner2", class: "inner"}, "world" ...> end ...> end [#div<[#span<%{id: "inner1", class: "inner"} ["hello "]>, #span<%{id: "inner2", class: "inner"} ["world"]>]>] iex> Eml.select(e, id: "inner1") [#span<%{id: "inner1", class: "inner"} ["hello "]>] iex> Eml.select(e, class: "inner") [#span<%{id: "inner1", class: "inner"} ["hello "]>, #span<%{id: "inner2", class: "inner"} ["world"]>] iex> Eml.select(e, class: "inner", id: "test") [] iex> Eml.select(e, pat: ~r/h.*o/) ["hello "] iex> Eml.select(e, pat: ~r/H.*o/, parent: true) [#span<%{id: "inner1", class: "inner"} ["hello "]>] """ @spec select(t) :: t def select(eml, opts \\ []) def select(content, opts) when is_list(content) do Enum.flat_map(content, &select(&1, opts)) end def select(%Template{}, _opts), do: [] def select(element, opts) do tag = opts[:tag] || :any id = opts[:id] || :any class = opts[:class] || :any pat = opts[:pat] select_parent? = opts[:parent] || false if select_parent? do if pat do pat_fun = fn element -> markup?(element) and Enum.any?(element.content, fn el -> is_binary(el) and Regex.match?(pat, el) end) end Enum.filter(element, pat_fun) else idclass_fun = fn element -> markup?(element) and Enum.any?(element.content, fn el -> Markup.match?(el, tag, id, class) end) end Enum.filter(element, idclass_fun) end else if pat do pat_fun = fn element when is_binary(element) -> Regex.match?(pat, element) _ -> false end Enum.filter(element, pat_fun) else Enum.filter(element, &Markup.match?(&1, tag, id, class)) end end end @doc """ Adds content to matched markup. It traverses and returns the complete eml tree. Markup is matched depending on the provided options. Those options can be: * `:tag` - match content by tag (`atom`) * `:id` - match content by id (`binary`) * `:class` - match content by class (`binary`) * `:at` - add new content at begin or end of existing content, default is `:end` (`:begin | :end`) When `:tag`, `:id`, or `:class` are combined, only markup is selected that satisfies all conditions. ## Examples: iex> e = eml do ...> div do ...> span [id: "inner1", class: "inner"], "hello " ...> span [id: "inner2", class: "inner"], "world" ...> end ...> end [#div<[#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.add(e, "dear ", id: "inner1") [#div<[#span<[id: "inner1", class: "inner"] ["hello dear "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.add(e, "__", class: "inner", at: :begin) [#div<[#span<[id: "inner1", class: "inner"] ["__hello "]>, #span<[id: "inner2", class: "inner"] ["__world"]>]>] iex> Eml.add(e, (eml do: span "!"), tag: :div) |> Eml.write!(pretty: false) "
hello world!
" """ @spec add(t, data, Keyword.t) :: t def add(eml, data, opts \\ []) do tag = opts[:tag] || :any id = opts[:id] || :any class = opts[:class] || :any add_fun = fn element -> if markup?(element) and Markup.match?(element, tag, id, class), do: Markup.add(element, data, opts), else: element end transform(eml, add_fun) end @doc """ Updates matched content. When content is matched, the provided function will be evaluated with the matched content as argument. When the provided function returns `nil`, the the content will be removed from the eml tree. Any other returned value will be evaluated by `Eml.read!/2` in order to guarantee valid eml. Content is matched depending on the provided options. Those options can be: * `:tag` - match markup content by tag (`atom`) * `:id` - match markup content by id (`binary`) * `:class` - match markup content by class (`binary`) * `:pat` - match binary content by regular expression (`RegEx.t`) * `:parent` - when set to true, selects the parent element of the matched content (`boolean`) When `:tag`, `:id`, or `:class` are combined, only markup is selected that satisfies all conditions. When the `:pat` options is used, `:tag`, `:id` and `:class` will be ignored. ## Examples: iex> e = eml do ...> div do ...> span [id: "inner1", class: "inner"], "hello " ...> span [id: "inner2", class: "inner"], "world" ...> end ...> end [#div<[#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.update(e, fn m -> Markup.id(m, "outer") end, tag: :div) [#div<[id: "outer"] [#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.update(e, fn m -> Markup.id(m, "outer") end, id: "inner2", parent: true) [#div<[id: "outer"] [#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.update(e, fn s -> String.upcase(s) end, pat: ~r/.*/) |> Eml.write!(pretty: false) "
HELLO WORLD
" """ @spec update(t, (element -> data), Keyword.t) :: t def update(eml, fun, opts \\ []) do tag = opts[:tag] || :any id = opts[:id] || :any class = opts[:class] || :any pat = opts[:pat] update_parent? = opts[:parent] || false update_fun = if update_parent? do if pat do fn element -> if markup?(element) and Enum.any?(element.content, fn el -> is_binary(el) and Regex.match?(pat, el) end), do: fun.(element), else: element end else fn element -> if markup?(element) and Enum.any?(element.content, fn el -> Markup.match?(el, tag, id, class) end), do: fun.(element), else: element end end else if pat do fn element -> if is_binary(element) and Regex.match?(pat, element), do: fun.(element), else: element end else fn element -> if Markup.match?(element, tag, id, class), do: fun.(element), else: element end end end transform(eml, update_fun) end @doc """ Removes matched content from the eml tree. See `update/3` for a description of the provided options. ## Examples: iex> e = eml do ...> div do ...> span [id: "inner1", class: "inner"], "hello " ...> span [id: "inner2", class: "inner"], "world" ...> end ...> end [#div<[#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.remove(e, tag: :div) [] iex> Eml.remove(e, id: "inner1") [#div<[#span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.remove(e, pat: ~r/.*/) [#div<[#span<[id: "inner1", class: "inner"]>, #span<[id: "inner2", class: "inner"]>]>] """ @spec remove(t, Keyword.t) :: t def remove(eml, opts \\ []) do tag = opts[:tag] || :any id = opts[:id] || :any class = opts[:class] || :any pat = opts[:pat] remove_parent? = opts[:parent] || false remove_fun = if remove_parent? do if pat do fn element -> if markup?(element) and Enum.any?(element.content, fn el -> is_binary(el) and Regex.match?(pat, el) end), do: nil, else: element end else fn element -> if markup?(element) and Enum.any?(element.content, fn el -> Markup.match?(el, tag, id, class) end), do: nil, else: element end end else if pat do fn element -> if is_binary(element) and Regex.match?(pat, element), do: nil, else: element end else fn element -> if Markup.match?(element, tag, id, class), do: nil, else: element end end end transform(eml, remove_fun) end @doc """ Returns true if there's at least one match with the provided options, returns false otherwise. In other words, returns true when the same select query would return a non-empty list. See `select/3` for a description of the provided options. ## Examples: iex> e = eml do ...> div do ...> span [id: "inner1", class: "inner"], "hello " ...> span [id: "inner2", class: "inner"], "world" ...> end ...> end [#div<[#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.member?(e, id: "inner1") true iex> Eml.member?(e, class: "inner", id: "test") false iex> Eml.member?(e, pat: ~r/h.*o/) true """ @spec member?(t, Keyword.t) :: boolean def member?(eml, opts) do case select(eml, opts) do [] -> false _ -> true end end @doc """ Recursively transforms content. This is the most low level operation provided by Eml for manipulating eml content. For example, `update/3` and `remove/2` are implemented by using this function. It accepts any eml and traverses all elements of the provided eml tree. The provided transform function will be evaluated for every element `transform/3` encounters. Parent elements will be transformed before their children. Child elements of a parent will be evaluated before moving to the next sibling. When the provided function returns `nil`, the the content will be removed from the eml tree. Any other returned value will be evaluated by `Eml.read!/2` in order to guarantee valid eml. Note that because parent elements are evaluated before their children, no children will be evaluated if the parent is removed. Accepts a lang as optional 3rd argument, in order to specify how transformed data should be interpreted, defaults to `Eml.Language.Native` ## Examples: iex> e = eml do ...> div do ...> span [id: "inner1", class: "inner"], "hello " ...> span [id: "inner2", class: "inner"], "world" ...> end ...> end [#div<[#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] iex> Eml.transform(e, fn x -> if Markup.has?(x, tag: :span), do: "matched", else: x end) [#div<["matched", "matched"]>] iex> Eml.transform(e, fn x -> ...> IO.puts(inspect x) ...> x end) #div<[#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]> #span<[id: "inner1", class: "inner"] ["hello "]> "hello " #span<[id: "inner2", class: "inner"] ["world"]> "world" [#div<[#span<[id: "inner1", class: "inner"] ["hello "]>, #span<[id: "inner2", class: "inner"] ["world"]>]>] """ @spec transform(t, (element -> data), lang) :: t | nil def transform(eml, fun, lang \\ Eml.Language.Native) def transform(eml, fun, lang) when is_list(eml) do for element <- eml, t = transform(element, fun, lang), do: t end def transform(element, fun, lang) do case element |> fun.() |> Readable.read(lang) do { :error, _ } -> nil element -> if markup?(element), do: %Markup{element| content: transform(element.content, fun, lang)}, else: element end end @spec read(data, lang) :: t | error def read(data, lang \\ @default_lang) do read(data, [], :begin, lang) end @spec read!(data, lang) :: t def read!(data, lang \\ @default_lang) do case read(data, lang) do { :error, e } -> raise ArgumentError, message: "Error #{inspect e}" eml -> eml end end @spec read_file(path, lang) :: t | error def read_file(path, lang \\ @default_lang) do case File.read(path) do { :ok, data } -> read(data, [], :begin, lang) { :error, e } -> { :error, e } end end @spec read_file!(path, lang) :: t | error def read_file!(path, lang \\ @default_lang) do File.read!(path) |> read!(lang) end @spec read(data | error, content, atom, lang) :: t | error def read(data, content, at, lang \\ Eml.Languages.Native) # Error pass through def read({ :error, e }, _, _, _), do: { :error, e } # No-ops def read(nondata, content, _, _) when nondata in [nil, "", []], do: content # Handle lists def read(data, content, :end, lang) when is_list(data), do: add_content(data, :lists.reverse(content), :end, lang) |> :lists.reverse() def read(data, content, :begin, lang) when is_list(data), do: add_content(:lists.reverse(data), content, :begin, lang) def read(data, content, mode, lang) do case Readable.read(data, lang) do { :error, e } -> { :error, e } element -> add_element(element, content, mode) end end # Optimize for most comon cases defp add_element(element, [], _) when is_list(element), do: element defp add_element(element, [], _), do: [element] defp add_element(element, [current], :end) do if is_binary(element) and is_binary(current) do [current <> element] else [current, element] end end defp add_element(element, [current], :begin) do if is_binary(element) and is_binary(current) do [element <> current] else [element, current] end end defp add_element(element, [h | t], :end) do if is_binary(element) and is_binary(h) do [h <> element | t] else [element, h | t] end end defp add_element(element, [h | t], :begin) do if is_binary(element) and is_binary(h) do [element <> h | t] else [element, h | t] end end defp add_content([h | t], content, mode, lang) do content = if is_list(h) and mode === :end, do: add_content(h, content, mode, lang), else: read(h, content, mode, lang) add_content(t, content, mode, lang) end defp add_content([], content, _, _), do: content @spec read!(data | error, content, atom, lang) :: t def read!(data, content, at, lang \\ @default_lang) do case read(data, content, at, lang) do { :error, e } -> raise ArgumentError, message: "Error #{e}" content -> content end end @spec write(t, Keyword.t) :: { :ok, binary } | error def write(eml, opts \\ []) def write(%Template{} = t, opts) do { lang, opts } = Keyword.pop(opts, :lang, @default_lang) lang.write(t, Keyword.put(opts, :mode, :compile)) end def write(eml, opts) do { lang, opts } = Keyword.pop(opts, :lang, @default_lang) lang.write(eml, Keyword.put(opts, :mode, :render)) end @spec write!(t, Keyword.t) :: binary def write!(eml, opts \\ []) do case write(eml, opts) do { :ok, str } -> str { :error, e } -> raise ArgumentError, message: inspect(e, pretty: true) end end @spec write_file(path, t, Keyword.t) :: :ok | error def write_file(path, eml, opts \\ []) do case write(eml, opts) do { :ok, str } -> File.write(path, str) error -> error end end @spec write_file!(path, t, Keyword.t) :: :ok def write_file!(path, eml, opts \\ []) do File.write!(path, write!(eml, opts)) end @spec compile(t, lang) :: Eml.Template.t | error def compile(eml, lang \\ @default_lang) def compile(%Template{} = t, _), do: t def compile(eml, lang) do # for consistence, when compiling eml we always want to return a template, even if # there are no parameters at all, or all of them are bound. case lang.write(eml, [mode: :compile, force_templ: true]) do { :ok, t } -> t error -> error end end @spec unpack(t) :: t def unpack(%Markup{content: [element]}), do: element def unpack(%Markup{content: content}), do: content def unpack([element]), do: element def unpack(eml), do: eml @spec unpackr(t) :: unpackr_result def unpackr(%Markup{content: [element]}), do: unpackr(element) def unpackr(%Markup{content: content}), do: unpack_content(content) def unpackr([element]), do: unpackr(element) def unpackr(content) when is_list(content), do: unpack_content(content) def unpackr(element), do: element defp unpack_content(content) do for element <- content, do: unpackr(element) end @spec funpackr(t) :: funpackr_result def funpackr(eml), do: unpackr(eml) |> :lists.flatten @spec markup?(term) :: boolean def markup?(%Markup{}), do: true def markup?(_), do: false @spec empty?(term) :: boolean def empty?(nil), do: true def empty?([]), do: true def empty?(%Markup{content: []}), do: true def empty?(_), do: false def type(content) when is_list(content) do if Enum.any?(content, fn el -> type(el) === :undefined end) do :undefined else :content end end def type(bin) when is_binary(bin), do: :binary def type(%Markup{}), do: :markup def type(%Template{}), do: :template def type(%Eml.Parameter{}), do: :parameter def type(_), do: :undefined # use Eml defmacro __using__(opts) do imports = if opts[:imports] != false do quote do import Eml, only: [eml: 1, defmarkup: 2, unpack: 1] end else quote do: require Eml end aliases = if opts[:aliases] != false do quote do alias Eml.Markup alias Eml.Template end end quote do require Eml.Markup unquote(imports) unquote(aliases) end end end