From 7d03903622ca80d09be0c4215c85c6b49b8872c0 Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 1 May 2024 23:31:39 -0400 Subject: [PATCH] Add converter --- .formatter.exs | 1 + lib/mix/tasks/convert_to_heex.ex | 247 +++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 lib/mix/tasks/convert_to_heex.ex diff --git a/.formatter.exs b/.formatter.exs index a058eaf5..b8da9def 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,5 @@ [ + heex_line_length: 300, import_deps: [:ecto, :phoenix], inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], plugins: [Phoenix.LiveView.HTMLFormatter], diff --git a/lib/mix/tasks/convert_to_heex.ex b/lib/mix/tasks/convert_to_heex.ex new file mode 100644 index 00000000..ad811f2c --- /dev/null +++ b/lib/mix/tasks/convert_to_heex.ex @@ -0,0 +1,247 @@ +defmodule Mix.Tasks.ConvertToHeex do + @moduledoc """ + Converts all slime files in the repository to HEEx. + + This file is substantially based on Slime's compiler, which can be found here: + https://github.com/slime-lang/slime/blob/master/lib/slime/compiler.ex + """ + + use Mix.Task + + alias Slime.Parser.Nodes.{ + DoctypeNode, + EExNode, + EExCommentNode, + HTMLCommentNode, + HTMLNode, + InlineHTMLNode, + VerbatimTextNode + } + + alias Slime.Doctype + + @void_elements ~w( + area br col doctype embed hr img input link meta base param + keygen source menuitem track wbr + ) + + @indent " " + + def run(_) do + Path.wildcard("lib/**/*.html.slime") + |> Enum.sort() + |> Enum.each(&format_file/1) + + :ok + end + + defp format_file(filename) do + Mix.shell().info(filename) + + formatted_content = + filename + |> File.read!() + |> format_string() + + heex_filename = String.replace(filename, ".html.slime", ".html.heex") + + File.write!(heex_filename, [formatted_content]) + File.rm!(filename) + end + + defp format_string(source) do + tree = Slime.Parser.parse(source) + compile(tree, "") + end + + defp compile(tags, indent) when is_list(tags) do + Enum.map(tags, &compile(&1, indent)) + end + + defp compile(%DoctypeNode{name: name}, indent), do: [indent, Doctype.for(name), "\n"] + + defp compile(%VerbatimTextNode{content: content}, indent) do + [indent, String.trim(IO.iodata_to_binary(content)), "\n"] + end + + defp compile(%HTMLNode{name: name} = tag, indent) do + attrs = Enum.map(tag.attributes, &render_attribute/1) + tag_head = Enum.join([name | attrs]) + + body = + cond do + tag.closed -> + ["<", tag_head, "/>\n"] + + name in @void_elements -> + ["<", tag_head, " />\n"] + + true -> + children = compile(tag.children, indent <> @indent) + inner = if(tag.children == [], do: [], else: ["\n", children, indent]) + + [ + "<", + tag_head, + ">", + inner, + "\n" + ] + end + + [indent, body] + end + + defp compile(%EExNode{content: code, output: output, safe?: safe?} = eex, indent) do + if safe? do + raise "== operator used to include safe content in template; mark as raw in view instead" + end + + tag_indent = + if(String.trim(code) == "else", do: unindent(indent), else: indent) + + code = reformat_code(code, eex.children) + + opening = [ + if(output, do: "<%= ", else: "<% "), + convert_multiline(code, tag_indent <> @indent), + "%>\n" + ] + + closing = + if Regex.match?(~r/(fn.*->| do)\s*$/, code) do + [indent, "<% end %>\n"] + else + "" + end + + [tag_indent, opening, compile(eex.children, tag_indent <> @indent), closing] + end + + defp compile(%InlineHTMLNode{}, _indent) do + raise "Inline HTML not supported" + end + + defp compile(%HTMLCommentNode{content: content}, indent) do + [indent, "\n"] + end + + defp compile(%EExCommentNode{content: content}, indent) do + content + |> raw() + |> IO.iodata_to_binary() + |> String.split("\n") + |> Enum.map(&[indent, "<% # ", &1, " %>\n"]) + end + + defp compile({:eex, eex}, indent), do: [indent, "<%= ", eex, " %>"] + defp compile({:safe_eex, _eex}, _indent), do: raise("Safe EEx not supported") + defp compile(raw, indent), do: [indent, raw] + + defp render_attribute({name, {safe_eex, content}}) do + if safe_eex != :eex do + raise "Unsupported attribute type '#{safe_eex}'" + end + + case content do + "true" -> + " #{name}" + + "false" -> + "" + + "nil" -> + "" + + _ -> + quoted_content = Code.string_to_quoted!(content) + render_attribute_code(name, content, quoted_content) + end + end + + defp render_attribute({name, value}) do + if value == true do + " #{name}" + else + value = + cond do + is_binary(value) -> value + is_list(value) -> Enum.join(value, " ") + true -> value + end + + ~s( #{name}="#{value}") + end + end + + defp render_attribute_code(name, _content, quoted) + when is_number(quoted) or is_atom(quoted) do + ~s[ #{name}="#{quoted}"] + end + + defp render_attribute_code(name, _content, quoted) when is_list(quoted) do + quoted |> Enum.map_join(" ", &Kernel.to_string/1) |> (&~s[ #{name}="#{&1}"]).() + end + + defp render_attribute_code(name, _content, quoted) when is_binary(quoted), + do: ~s[ #{name}="#{quoted}"] + + # String with interpolation + defp render_attribute_code( + name, + _content, + {:<<>>, _, [{:"::", _, [{{:., _, [Kernel, :to_string]}, _, [line]}, _]}]} + ) do + ~s[ #{name}={#{Macro.to_string(line)}}] + end + + defp render_attribute_code(name, content, _) do + ~s[ #{name}={#{content}}] + end + + defp raw(value) when is_list(value) do + Enum.map(value, &raw/1) + end + + defp raw({:eex, value}), do: "\#{" <> value <> "}" + defp raw(value), do: value + + defp convert_multiline(code, indent) do + case String.split(code, "\n") do + [_line] -> + [code, " "] + + lines -> + lines = + Enum.map(lines, fn line -> + if String.trim(line) == "" do + ["\n"] + else + [indent, line, "\n"] + end + end) + + ["\n", lines, unindent(indent)] + end + end + + defp unindent(indent), do: String.slice(indent, 0..-3//1) + + defp reformat_code(code, []) do + case Code.string_to_quoted(code) do + {:ok, ast} -> + ast + |> Code.quoted_to_algebra() + |> Inspect.Algebra.format(300) + |> IO.iodata_to_binary() + + _ -> + # Stab or do block + code + end + end + + defp reformat_code(code, _), do: code +end