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 ex_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{</?form[>\s]}i, txt, return: :index)
accums =
Enum.map(
forms,
fn [{i, l}] ->
if String.starts_with?(String.downcase(String.slice(txt, i, l)), "<form") do
{:open, false, t}
else
{:close, i, t}
end
end
)
find_form_tags(Enum.reverse(accums) ++ accum, rest)
end
defp find_form_tags(accum, [_ | rest]) do
find_form_tags(accum, rest)
end
defp find_form_tags(accum, []) do
Enum.reverse(accum)
end
defp pair_open_close_forms(accum, _currently_open, []) do
Enum.reverse(accum)
end
defp pair_open_close_forms(accum, currently_open, [f = {:open, _is_live, _tok} | rest]) do
pair_open_close_forms(accum, [f | currently_open], rest)
end
defp pair_open_close_forms(accum, [], [{:close, _i, _tok} | rest]) do
# Ignore unmatched closers
pair_open_close_forms(accum, [], rest)
end
defp pair_open_close_forms(accum, [o | os], [c = {:close, _i, _tok} | rest]) do
pair_open_close_forms([{o, c} | accum], os, rest)
end
defp form_replacements(str, forms) do
open_close_pairs = pair_open_close_forms([], [], forms)
open_close_pairs
|> 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("</form>"), "</.form>"}
[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