defmodule EexToHeex do @moduledoc """ EexToHeex performs best effort conversion of html.eex templates to heex. The output is not guaranteed to be correct. However, conversion works correctly for a sufficiently wide range of input templates that the amount of manual conversion work can be significantly reduced. See https://github.com/phoenixframework/phoenix_live_view/blob/master/CHANGELOG.md#new-html-engine for information on the differences between eex and heex templates. """ alias Phoenix.LiveView.HTMLEngine @doc """ Performs best effort conversion of an html.eex template to a heex template. Returns `{:ok, output_string}` if successful, or `{:error, output_string, error}` on error. In the latter case, `output_string` may be `nil` if the error occurred before any output was generated. On success, the output is guaranteed to be a valid heex template (since it has passed successfully through `HTMLEngine.compile`). However, there is no general guarantee that the output template will have exactly the same behavior as the input template. """ @spec eex_to_heex(String.t()) :: {:ok, String.t()} | {:error, String.t() | nil, any()} def eex_to_heex(str) do with {:ok, toks} <- EEx.Tokenizer.tokenize(str, _start_line = 1, _start_col = 0, %{ trim: false, indentation: 0 }) do toks = fudge_tokens(toks) attrs = find_attrs(false, false, [], toks) attr_reps = Enum.flat_map(attrs, fn {quoted, subs} -> attr_replacements(str, quoted, subs) end) forms = find_form_tags([], toks) form_reps = form_replacements(str, forms) output = multireplace(str, attr_reps ++ form_reps) check_output(output) else {:error, err} -> {:error, nil, err} end end @doc """ Performs best effort conversion of inline ~L templates to ~H templates. Returns `{:ok, output_string}` if successful, or `{:error, output_string, error}` on error. In the latter case, `output_string` may be `nil` if the error occurred before any output was generated. On success, the inline ~H templates are guaranteed to be valid, (since they've passed successfully through `HTMLEngine.compile`). However, there is no general guarantee that the output templates will have exactly the same behavior as the input templates. """ @spec eex_to_heex(String.t()) :: {:ok, String.t()} | {:error, String.t() | nil, any()} def ex_to_heex(str) do with {:ok, ast} <- Code.string_to_quoted(str, columns: true) do {_, transformed} = Macro.prewalk(ast, {:ok, str}, &transform_ex/2) transformed else {:error, err} -> {:error, nil, err} end end defp transform_ex( {:sigil_L, [delimiter: _, line: line, column: column], children} = ast, {:ok, str} ) do transformed = str |> replace(line, column, "~L", "~H") |> transform_leex(children) {ast, transformed} end defp transform_ex(ast, str), do: {ast, str} defp transform_leex(str, [{:<<>>, [line: line, column: column], [leex]}, []]) do case eex_to_heex(leex) do {:ok, replacement} -> indentation = String.length("~L|") replacement = replace(str, line, column + indentation, leex, replacement) {:ok, replacement} other -> other end end defp transform_leex(str, [ {:<<>>, [indentation: indentation, line: line, column: _], [leex]}, [] ]) do case eex_to_heex(leex) do {:ok, heex} -> replacement = leex |> String.split("\n") |> Enum.zip(String.split(heex, "\n")) |> Enum.with_index() |> Enum.reduce(str, fn {{from, to}, index}, str -> replace(str, line + index + 1, indentation + 1, from, to) end) {:ok, replacement} other -> other end end defp replace(text, line_num, column_num, from, to) do for {line, line_index} <- text |> String.split("\n") |> Enum.with_index() do if line_index + 1 == line_num do replace_at(line, column_num - 1, from, to) else line end end |> Enum.join("\n") end defp replace_at(text, position, from, to) do {a, b} = String.split_at(text, position) to_replace = String.slice(b, 0, String.length(from)) if to_replace != from do raise "Attempted to replace:\n\n#{from}\n\nbut found:\n\n#{to_replace}\n\nat position #{ position }" end a <> String.replace_prefix(b, from, to) end defp check_output(output) do with {:ok, tmp_path} <- Briefly.create(), :ok <- File.write(tmp_path, output) do try do # Phoenix.LiveView.HTMLEngine ignores its second param HTMLEngine.compile(tmp_path, "foo.html.heex") {:ok, output} rescue err -> {:error, output, err} end else {:error, err} -> {:error, output, err} end end # Column information for some tokens is systematically off by a few chars. defp fudge_tokens(tokens) do Enum.map(tokens, fn tok -> case tok do {:text, l, c, t} -> {:text, l, if l == 1 do c else c - 1 end, t} {:expr, l, c, eq, expr} -> {:expr, l, if l == 1 do c + 3 else c + 2 end, eq, expr} _ -> tok end end) end defp find_form_tags(accum, [t = {:expr, _, _, '=', txt} | rest]) do txt = to_string(txt) if txt =~ ~r/^\s*[[:alnum:]_]+\s*=\s*form_for[\s|(]/ and not (txt =~ ~r/\s->\s*$/) do find_form_tags([{:open, true, t} | accum], rest) else find_form_tags(accum, rest) end end defp find_form_tags(accum, [t = {:text, _, _, txt} | rest]) do txt = to_string(txt) forms = Regex.scan(~r{\s]}i, txt, return: :index) accums = Enum.map( forms, fn [{i, l}] -> if String.starts_with?(String.downcase(String.slice(txt, i, l)), " Enum.flat_map(fn {{:open, is_live, otok}, {:close, ci, ctok}} -> if is_live do # <%= f = form_for ... %> -> <.form ...> {:expr, tl, tc, '=', expr} = otok expr = to_string(expr) dot_form = mung_form_for(Code.string_to_quoted!(expr)) ff_start = get_index(str, tl, tc) {:text, l, c, _} = ctok close_start = get_index(str, l, c) + ci ff_repl = { scan_to_char(str, "<", -1, ff_start), scan_to_char(str, ">", 1, ff_start + String.length(expr)) + 1, dot_form } close_repl = {close_start, close_start + String.length(""), ""} [close_repl, ff_repl] else [] end end) end defp mung_form_for( {:=, _, [ f = {_, _, _}, {:form_for, _, [ changeset, url | more_args ]} ]} ) do extras = Enum.reduce( List.first(more_args) || [], "", fn {k, v}, s -> s <> " #{String.replace(Atom.to_string(k), "_", "-")}=#{brace_wrap(Macro.to_string(v))}" end ) "<.form let={#{Macro.to_string(f)}} for=#{brace_wrap(Macro.to_string(changeset))} url=#{ brace_wrap(Macro.to_string(url)) }#{extras}>" end defp brace_wrap(s = "\"" <> _) do s end defp brace_wrap(val) do "{#{val}}" end defp find_attrs( inside_tag?, just_subbed?, accum, [{:text, _, _, txt}, e = {:expr, _, _, '=', _contents} | rest] ) do txt = to_string(txt) # Strip the trailing part of the last attr of this tag if there was one and it was quoted. txt = case {just_subbed?, List.first(accum)} do {true, {quoted, _}} when quoted != nil -> String.replace(txt, ~r/^[^#{quoted}]+/, "") _ -> txt end {inside_tag?, _offset} = update_inside_tag(inside_tag?, txt) if inside_tag? do case Regex.run( ~r/\s*[[:alnum:]-]+=\s*(?:(?:\s*)|(?:"([^"]*))|(?:'([^']*)))$/, String.slice(txt, 0..-1) ) do [_, prefix] -> {subs, rest} = find_subs("\"", [{e, prefix, ""}], rest) find_attrs(inside_tag?, _just_subbed? = true, [{_quoted = "\"", subs} | accum], rest) [_, _, prefix] -> {subs, rest} = find_subs("'", [{e, prefix, ""}], rest) find_attrs(inside_tag?, _just_subbed? = true, [{_quoted = "'", subs} | accum], rest) [_] -> find_attrs( inside_tag?, _just_subbed? = true, [{_quoted = nil, [{e, "", ""}]} | accum], rest ) _ -> find_attrs(inside_tag?, _just_subbed? = false, accum, rest) end else find_attrs(inside_tag?, _just_subbed? = false, accum, rest) end end defp find_attrs(inside_tag?, _just_subbed?, accum, [{:text, _, _, txt} | rest]) do txt = to_string(txt) {inside_tag?, _} = update_inside_tag(inside_tag?, txt) find_attrs(inside_tag?, _just_subbed? = false, accum, rest) end defp find_attrs(inside_tag?, _just_subbed?, accum, [_ | rest]) do find_attrs(inside_tag?, _just_subbed? = false, accum, rest) end defp find_attrs(_inside_tag?, _just_subbed?, accum, []) do Enum.reverse(accum) end defp update_inside_tag(inside_tag?, txt) do case Regex.run(~r/<[[:alnum:]_]+[\s>][^>]*$/, txt, return: :index) do [{offset, _}] -> {true, offset} nil -> {inside_tag? and not String.contains?(txt, ">"), 0} end end defp find_subs( quoted, accum = [{e, prefix, _suffix} | arest], toks = [{:text, _, _, txt} | trest] ) do txt = to_string(txt) case Regex.run(~r/^([^#{quoted}]*)(.?)/, txt) do [_, suffix, en] -> accum = [{e, prefix, suffix} | arest] if en == quoted do {Enum.reverse(accum), toks} else find_subs(quoted, accum, trest) end nil -> find_subs(quoted, accum, trest) end end defp find_subs(quoted, accum, [e = {:expr, _, _, '=', _contents} | rest]) do find_subs(quoted, [{e, "", ""} | accum], rest) end defp find_subs(_quoted, accum, toks) do {Enum.reverse(accum), toks} end defp attr_replacements(str, _quoted = nil, [{{:expr, l, c, _, expr}, "", ""}]) do expr = to_string(expr) expr_start = get_index(str, l, c) expr_end = expr_start + String.length(expr) open = scan_to_char(str, "<", -1, expr_start) close = scan_to_char(str, ">", 1, expr_end) [{open, expr_start, "{\"\#{"}, {expr_start, expr_end, expr}, {expr_end, close + 1, "}\"}"}] end defp attr_replacements(str, quoted, subs = [_ | _]) do subs_len = length(subs) subs |> Enum.with_index() |> Enum.flat_map(fn {{{:expr, l, c, _, expr}, prefix, suffix}, i} -> expr = to_string(expr) expr_start = get_index(str, l, c) expr_end = expr_start + String.length(expr) opener = if i == 0 do open = scan_to_char(str, quoted, -1, expr_start) {open, expr_start, "{\""} else open = scan_to_char(str, "<", -1, expr_start) {open, expr_start, ""} end closer = if i == subs_len - 1 do close = scan_to_char(str, quoted, 1, expr_end) {expr_end, close + 1, "\"}"} else close = scan_to_char(str, ">", 1, expr_end) {expr_end, close + 1 + String.length(suffix), ""} end [opener] ++ [{expr_start, expr_end, "#{estring(prefix)}\#{#{expr}}#{estring(suffix)}"}] ++ [closer] end) end defp estring("" <> str) do decoded = HtmlEntities.decode(str) s = inspect(decoded) String.slice(s, 1, String.length(s) - 2) end defp scan_to_char(str, c, inc, i) do cond do i < 0 || i >= String.length(str) -> -1 String.at(str, i) == c -> i true -> scan_to_char(str, c, inc, i + inc) end end defp multireplace(str, replacements) do {_, new_s} = replacements |> Enum.sort_by(fn {i, _, _} -> i end) |> Enum.reduce( {0, str}, fn {i, j, rep}, {offset, new_s} -> { offset + String.length(rep) - (j - i), String.slice(new_s, 0, i + offset) <> rep <> String.slice(new_s, j + offset, String.length(new_s)) } end ) new_s end defp get_index(s, line, col) do get_index_helper(s, line, col, 1, 0, 0) end defp get_index_helper(_, line, col, line, col, index) do index end defp get_index_helper("", _line, _col, _current_line, _current_col, _index) do -1 end defp get_index_helper("\n" <> rest, line, col, current_line, _current_col, index) do get_index_helper(rest, line, col, current_line + 1, _current_col = 0, index + 1) end defp get_index_helper(str, line, col, current_line, current_col, index) do get_index_helper( String.slice(str, 1..-1), line, col, current_line, current_col + 1, index + 1 ) end end