diff --git a/.formatter.exs b/.formatter.exs index 679731a4f0..bc8336d1cd 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -18,6 +18,7 @@ test_locals_without_parens = [ [ export: [locals_without_parens: exported_locals_without_parens], + plugins: [Hologram.Template.Formatter], import_deps: [:phoenix], inputs: Enum.flat_map( diff --git a/lib/hologram/template/algebra.ex b/lib/hologram/template/algebra.ex new file mode 100644 index 0000000000..0d428cb252 --- /dev/null +++ b/lib/hologram/template/algebra.ex @@ -0,0 +1,604 @@ +defmodule Hologram.Template.Algebra do + @moduledoc """ + The `Inspect.Algebra` for Hologram templates + """ + + import Inspect.Algebra + + @inline_tags ~w(a abbr b bdi bdo br button canvas cite code data datalist del dfn em embed i iframe img input ins kbd label map mark meter noscript object output picture progress q ruby s samp script select slot small span strong sub sup svg template textarea time u var video wbr) + @inviolable_tags ~w(script style) + @whitespace_regex ~r/\s+/ + + def format_tokens(tokens, opts \\ []) do + tokens + |> cleanup_tokens() + |> parse_tokens() + |> join_nodes(opts) + end + + defp cleanup_tokens(tokens) do + do_cleanup_tokens(tokens, nil) + end + + defp do_cleanup_tokens([], _last_text), do: [] + + defp do_cleanup_tokens([{:block_start, "raw"} = token | rest], last_text) do + case rest do + [{:text, t} | tail] when not is_nil(last_text) -> + if String.starts_with?(t, last_text) do + new_t = String.replace_prefix(t, last_text, "") + [token | [{:text, new_t} | do_cleanup_tokens(tail, nil)]] + else + [token | do_cleanup_tokens(rest, nil)] + end + + _ -> + [token | do_cleanup_tokens(rest, nil)] + end + end + + defp do_cleanup_tokens([{:text, t} = token | rest], _last_text) do + [token | do_cleanup_tokens(rest, t)] + end + + defp do_cleanup_tokens([token | rest], _last_text) do + [token | do_cleanup_tokens(rest, nil)] + end + + defp parse_tokens(tokens) do + do_parse_tokens(tokens, []) + end + + defp do_parse_tokens([], acc), do: Enum.reverse(acc) + + defp do_parse_tokens([{:start_tag, {tag, attrs}} | rest], acc) do + {content_tokens, rest_after} = consume_until_end_tag(rest, tag) + + content = + if tag in @inviolable_tags do + content_tokens + else + parse_tokens(content_tokens) + end + + do_parse_tokens(rest_after, [{:start_tag, tag, attrs, content} | acc]) + end + + defp do_parse_tokens([{:block_start, {tag, exp}} | rest], acc) do + {content_tokens, rest_after} = consume_until_end_block(rest, tag) + do_parse_tokens(rest_after, [{:block_start, tag, exp, parse_tokens(content_tokens)} | acc]) + end + + defp do_parse_tokens([{:block_start, tag} | rest], acc) when is_binary(tag) do + cond do + tag == "raw" -> + {content_tokens, rest_after} = consume_until_end_block(rest, tag) + do_parse_tokens(rest_after, [{:raw, content_tokens} | acc]) + + tag == "else" -> + do_parse_tokens(rest, [{:block_interior, tag} | acc]) + + true -> + {content_tokens, rest_after} = consume_until_end_block(rest, tag) + do_parse_tokens(rest_after, [{:block_start, tag, "", parse_tokens(content_tokens)} | acc]) + end + end + + defp do_parse_tokens([{:self_closing_tag, {tag, attrs}} | rest], acc) do + do_parse_tokens(rest, [{:self_closing_tag, tag, attrs} | acc]) + end + + defp do_parse_tokens([{:text, _} = text | rest], acc) do + do_parse_tokens(rest, [text | acc]) + end + + defp do_parse_tokens([{:expression, _} = exp | rest], acc) do + do_parse_tokens(rest, [exp | acc]) + end + + defp do_parse_tokens([{:doctype, _} = doctype | rest], acc) do + do_parse_tokens(rest, [doctype | acc]) + end + + defp do_parse_tokens([:public_comment_start | rest], acc) do + {content_tokens, rest_after} = consume_until_public_comment_end(rest) + do_parse_tokens(rest_after, [{:public_comment, parse_tokens(content_tokens)} | acc]) + end + + defp do_parse_tokens([_token | rest], acc) do + do_parse_tokens(rest, acc) + end + + defp consume_until_end_tag(tokens, tag_to_match) do + do_consume_until_end_tag(tokens, tag_to_match, [], 0) + end + + defp do_consume_until_end_tag([{:start_tag, {tag, _}} = token | rest], tag_to_match, acc, level) + when tag == tag_to_match do + do_consume_until_end_tag(rest, tag_to_match, [token | acc], level + 1) + end + + defp do_consume_until_end_tag([{:end_tag, tag} = _token | rest], tag_to_match, acc, level) + when tag == tag_to_match do + if level == 0 do + {Enum.reverse(acc), rest} + else + do_consume_until_end_tag(rest, tag_to_match, [{:end_tag, tag} | acc], level - 1) + end + end + + defp do_consume_until_end_tag([token | rest], tag_to_match, acc, level) do + do_consume_until_end_tag(rest, tag_to_match, [token | acc], level) + end + + defp consume_until_end_block(tokens, tag_to_match) do + do_consume_until_end_block(tokens, tag_to_match, [], 0) + end + + defp do_consume_until_end_block( + [{:block_start, {tag, _}} = token | rest], + tag_to_match, + acc, + level + ) + when tag == tag_to_match do + do_consume_until_end_block(rest, tag_to_match, [token | acc], level + 1) + end + + defp do_consume_until_end_block([{:block_end, tag} = _token | rest], tag_to_match, acc, level) + when tag == tag_to_match do + if level == 0 do + {Enum.reverse(acc), rest} + else + do_consume_until_end_block(rest, tag_to_match, [{:block_end, tag} | acc], level - 1) + end + end + + defp do_consume_until_end_block([token | rest], tag_to_match, acc, level) do + do_consume_until_end_block(rest, tag_to_match, [token | acc], level) + end + + defp consume_until_public_comment_end(tokens) do + do_consume_until_public_comment_end(tokens, []) + end + + defp do_consume_until_public_comment_end([:public_comment_end | rest], acc) do + {Enum.reverse(acc), rest} + end + + defp do_consume_until_public_comment_end([token | rest], acc) do + do_consume_until_public_comment_end(rest, [token | acc]) + end + + defp from_node(node, opts) do + case node do + {:start_tag, tag, attrs, content_nodes} -> + open = group(concat([string("<"), string(tag), from_attrs(attrs), string(">")])) + close = string(" tag <> ">") + + if tag in @inviolable_tags do + if content_nodes == [] do + concat(open, close) + else + {content, _raw?} = + Enum.reduce(content_nodes, {"", false}, fn node, {acc_content, acc_raw?} -> + case node do + {:block_start, "raw"} -> + {acc_content <> tag_to_markup(node, acc_raw?), true} + + {:block_end, "raw"} -> + {acc_content <> tag_to_markup(node, true), false} + + _ -> + {acc_content <> tag_to_markup(node, acc_raw?), acc_raw?} + end + end) + + if String.contains?(content, "\n") do + lines = + content + |> String.split("\n") + |> Enum.map(&String.trim_trailing/1) + |> Enum.drop_while(&(String.trim(&1) == "")) + |> Enum.reverse() + |> Enum.drop_while(&(String.trim(&1) == "")) + |> Enum.reverse() + + common_indent = + lines + |> Enum.filter(&(String.trim(&1) != "")) + |> Enum.map(fn line -> + case Regex.run(~r/^\s*/, line) do + [indent] -> String.length(indent) + _ -> 0 + end + end) + |> case do + [] -> 0 + indents -> Enum.min(indents) + end + + content_doc = + lines + |> Enum.map(fn line -> + if String.trim(line) == "" do + "" + else + String.slice(line, common_indent..-1//1) + end + end) + |> Enum.map(&string/1) + |> Enum.intersperse(line()) + |> concat() + + concat([open, nest(concat(line(), content_doc), 2), line(), close]) + else + concat([open, string(content), close]) + end + end + else + content_doc = join_nodes(content_nodes, opts) + + if (tag in @inline_tags or opts[:layout] == :inline) and + can_be_inline?(content_nodes, opts) do + group(concat([open, nest(content_doc, 2), close])) + else + if content_doc == empty() do + concat(open, close) + else + concat([open, nest(concat(line(), content_doc), 2), line(), close]) + end + end + end + + {:self_closing_tag, tag, attrs} -> + group(concat([string("<"), string(tag), from_attrs(attrs), break(" "), string("/>")])) + + {:block_start, tag, exp, content_nodes} -> + open = concat([string("{%"), string(tag), from_block_expression(exp), string("}")]) + close = concat([string("{/"), string(tag), string("}")]) + + if content_nodes == [] do + concat(open, close) + else + content_doc = format_block_content(content_nodes, opts) + concat([open, content_doc, line(), close]) + end + + {:block_interior, tag} -> + concat([line(), concat([string("{%"), string(tag), string("}")])]) + + {:expression, e} -> + content = expression_content(e) + + case Code.string_to_quoted(content) do + {:ok, ast} -> + group( + concat([ + string("{"), + nest(Code.quoted_to_algebra(ast, []), 2), + string("}") + ]) + ) + + _ -> + string(tighten_expression(e)) + end + + {:text, t} -> + t = + t + |> String.replace("{", "\\{") + |> String.replace("}", "\\}") + + if String.trim(t) == "" do + empty() + else + if String.contains?(t, "\n") do + t + |> String.split(~r/\n\s*/) + |> Enum.map(&string/1) + |> Enum.intersperse(line()) + |> concat() + else + doc = + t + |> String.replace(@whitespace_regex, " ") + |> String.split(" ") + |> Enum.map(&string/1) + |> Enum.intersperse(break(" ")) + |> concat() + + group(doc) + end + end + + {:raw, content_tags} -> + content = Enum.map_join(content_tags, "", &tag_to_markup(&1, true)) + concat([string("{%raw}"), string(content), string("{/raw}")]) + + {:doctype, content} -> + concat([string(""), line()]) + + {:public_comment, content_nodes} -> + content_doc = join_nodes(content_nodes, opts) + concat([string("")]) + + _ -> + empty() + end + end + + defp format_block_content(nodes, opts) do + nodes + |> Enum.chunk_by(fn node -> match?({:block_interior, _}, node) end) + |> Enum.map(fn + [{:block_interior, tag}] -> + from_node({:block_interior, tag}, opts) + + group -> + nest(concat(line(), join_nodes(group, opts)), 2) + end) + |> concat() + end + + defp join_nodes(nodes, opts) do + nodes + |> Enum.chunk_by(&is_inline_node?(&1, opts)) + |> Enum.map(fn group -> + if is_inline_node?(hd(group), opts) do + join_inline_group(group, opts) + else + join_block_group(group, opts) + end + end) + |> Enum.intersperse(if opts[:layout] == :inline, do: break(""), else: line()) + |> flatten_docs() + |> dedup_lines() + |> trim_lines() + |> concat() + end + + defp is_inline_node?(node, opts) do + case node do + {:text, _} -> true + _ -> opts[:layout] == :inline or can_be_inline?([node], opts) + end + end + + defp join_inline_group(nodes, opts) do + nodes + |> Enum.map(&from_node(&1, opts)) + |> Enum.filter(fn doc -> doc != empty() end) + |> Enum.intersperse(break("")) + |> flatten_docs() + |> dedup_lines() + |> trim_lines() + |> concat() + end + + defp join_block_group(nodes, opts) do + nodes + |> Enum.map(&from_node(&1, opts)) + |> Enum.filter(fn doc -> doc != empty() end) + |> Enum.intersperse(line()) + |> flatten_docs() + |> dedup_lines() + |> trim_lines() + |> concat() + end + + defp flatten_docs(docs) do + docs + |> Enum.flat_map(fn + {:doc_cons, a, b} -> flatten_docs([a, b]) + doc -> [doc] + end) + |> Enum.filter(fn doc -> not is_empty_doc?(doc) end) + end + + defp dedup_lines(docs) do + Enum.reduce(docs, [], fn + doc, [] -> + [doc] + + doc, [prev | rest] = acc -> + case {doc, prev} do + {:doc_line, :doc_line} -> + case rest do + [:doc_line | _] -> acc + _ -> [doc | acc] + end + + {:doc_line, {:doc_break, _, _}} -> + [doc | rest] + + {{:doc_break, _, _}, :doc_line} -> + acc + + {{:doc_break, b1, _}, {:doc_break, b2, _}} -> + if b1 == " " or b2 == " " do + [break(" ") | rest] + else + [doc | rest] + end + + _ -> + if is_empty_doc?(doc) do + acc + else + [doc | acc] + end + end + end) + |> Enum.reverse() + end + + defp trim_lines(docs) do + docs + |> Enum.drop_while(fn doc -> + doc == line() or match?({:doc_break, " ", _}, doc) or is_empty_doc?(doc) + end) + |> Enum.reverse() + |> Enum.drop_while(fn doc -> + doc == line() or match?({:doc_break, " ", _}, doc) or is_empty_doc?(doc) + end) + |> Enum.reverse() + end + + defp is_empty_doc?(doc) do + doc == empty() or doc == string("") or doc == "" + end + + defp can_be_inline?([], _opts), do: true + + defp can_be_inline?(nodes, opts) do + Enum.all?(nodes, fn + {:expression, _} -> true + {:text, t} -> not String.contains?(t, "\n") + {:start_tag, tag, _, _} -> tag in @inline_tags or opts[:layout] == :inline + {:self_closing_tag, tag, _} -> tag in @inline_tags or opts[:layout] == :inline + {:raw, _} -> true + _ -> false + end) + end + + defp from_attrs([]), do: empty() + + defp from_attrs(attrs) do + attrs_doc = + attrs + |> Enum.map(fn {key, value} -> + group(concat(string(key), from_attr_value(value))) + end) + |> Enum.intersperse(break(" ")) + |> concat() + + nest(concat(break(" "), attrs_doc), 2) + end + + defp from_attr_value([]), do: empty() + + defp from_attr_value(parts) when is_list(parts) do + relevant_parts = + Enum.filter(parts, fn + {:text, t} -> String.trim(t) != "" + {:expression, _} -> true + end) + + if relevant_parts == [] do + empty() + else + value = + Enum.map_join(parts, fn + {:text, t} -> t + {:expression, e} -> tighten_expression(e) + end) + + if length(relevant_parts) == 1 and elem(hd(relevant_parts), 0) == :expression do + concat(string("="), string(value)) + else + concat(string("="), string("\"" <> value <> "\"")) + end + end + end + + defp expression_content(estr) do + estr + |> String.trim() + |> String.trim_leading("{") + |> String.trim_trailing("}") + |> case do + ^estr -> estr |> String.trim() + trimmed -> expression_content(trimmed) + end + end + + defp tighten_expression(estr) do + "{" <> expression_content(estr) <> "}" + end + + defp from_block_expression(""), do: empty() + + defp from_block_expression(e) do + content = expression_content(e) + if content == "", do: empty(), else: concat(break(" "), string(content)) + end + + defp tag_to_markup(tag, raw?) do + case tag do + {:symbol, s} -> + s + + {:string, s} -> + s + + {:whitespace, s} -> + s + + {:text, t} -> + if raw? do + t + else + t |> String.replace("{", "\\{") |> String.replace("}", "\\}") + end + + {:expression, e} -> + e + + {:start_tag, {tag, attrs}} -> + "<" <> tag <> attrs_to_markup(attrs) <> ">" + + {:end_tag, tag} -> + " tag <> ">" + + {:self_closing_tag, {tag, attrs}} -> + "<" <> tag <> attrs_to_markup(attrs) <> "/>" + + {:block_start, {tag, exp}} -> + suffix = if exp != "", do: " " <> exp, else: "" + "{%" <> tag <> suffix <> "}" + + {:block_start, tag} -> + "{%" <> tag <> "}" + + {:block_end, tag} -> + "{/" <> tag <> "}" + + {:doctype, content} -> + " content <> "> +" + + :public_comment_start -> + "" + + _ -> + "" + end + end + + defp attrs_to_markup([]), do: "" + + defp attrs_to_markup(attrs) do + Enum.map_join(attrs, "", fn {key, value} -> + " " <> key <> "=" <> attr_value_to_markup(value) + end) + end + + defp attr_value_to_markup(value) when is_list(value) do + inner = + Enum.map_join(value, "", fn + {:text, t} -> t + {:expression, e} -> e + end) + + if length(value) == 1 and elem(hd(value), 0) == :expression do + inner + else + "\"" <> inner <> "\"" + end + end +end diff --git a/lib/hologram/template/formatter.ex b/lib/hologram/template/formatter.ex new file mode 100644 index 0000000000..3166650821 --- /dev/null +++ b/lib/hologram/template/formatter.ex @@ -0,0 +1,47 @@ +defmodule Hologram.Template.Formatter do + @moduledoc """ + A formatter for `~HOLO` sigil templates. + + Enable it by adding `Hologram.Template.Formatter` to the `plugins:` list + in `.formatter.exs` + """ + + @behaviour Mix.Tasks.Format + alias Hologram.Template.{Parser, Algebra} + + @impl Mix.Tasks.Format + def features(_opts) do + # Will `.holo` be a thing eventually? + [sigils: [:HOLO], extensions: [".holo"]] + end + + @impl Mix.Tasks.Format + def format(contents, opts) do + line_length = opts[:line_length] || 98 + + case Regex.run(~r/^(\s*)(.*?)(\s*)$/s, contents) do + [_, leading, middle, trailing] -> + layout = if String.contains?(middle, "\n"), do: :block, else: :inline + + formatted_middle = + middle + |> Parser.parse_markup() + |> Algebra.format_tokens(layout: layout) + |> Inspect.Algebra.format(line_length) + |> IO.iodata_to_binary() + |> String.split("\n") + |> Enum.map(&String.trim_trailing/1) + |> Enum.join("\n") + |> String.trim() + + if String.contains?(trailing, "\n") do + leading <> formatted_middle <> "\n" + else + leading <> formatted_middle + end + + _ -> + contents + end + end +end diff --git a/lib/hologram/template/parser.ex b/lib/hologram/template/parser.ex index b39996c73e..3cd8587467 100644 --- a/lib/hologram/template/parser.ex +++ b/lib/hologram/template/parser.ex @@ -645,6 +645,7 @@ defmodule Hologram.Template.Parser do def parse_tokens(%{raw?: false} = context, :text, [{:symbol, "{%raw}"} = token | rest]) do context + |> maybe_add_text_tag() |> add_processed_token(token) |> add_processed_tag({:block_start, "raw"}) |> enable_raw_mode() diff --git a/mix.lock b/mix.lock index 6221a5df20..52d7a61ca4 100644 --- a/mix.lock +++ b/mix.lock @@ -11,7 +11,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "escape": {:hex, :escape, "0.4.0", "3cf80059da1d499bc82fa6480215360c461dc216d7d52dc054f72eb1449f2c9e", [:mix], [], "hexpm", "b29918ba0741e429c6cc9af7c8792fe6f5a2ad7d35b6f5e88d56c670401c3a61"}, + "escape": {:hex, :escape, "0.4.1", "065164d0d18a70e75092fc71124978e7b50ac99de69748739e48609825ab1c9e", [:mix], [], "hexpm", "867d641d66ffb1afbb1cdf872eff3c21b1af90fec3d21916b1b5eda51e244158"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, @@ -26,7 +26,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, - "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, @@ -44,5 +44,5 @@ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/test/elixir/hologram/template/formatter_test.exs b/test/elixir/hologram/template/formatter_test.exs new file mode 100644 index 0000000000..09b0f7837d --- /dev/null +++ b/test/elixir/hologram/template/formatter_test.exs @@ -0,0 +1,263 @@ +defmodule Hologram.Template.FormatterTest do + use Hologram.Test.BasicCase, async: true + import Hologram.Template.Formatter + alias Hologram.TemplateSyntaxError + + defp format_as_binary(t) do + t + |> format([]) + |> IO.iodata_to_binary() + end + + describe "basic elements" do + test "inline elements remain inline and normalize whitespace" do + input = " abc " + assert format_as_binary(input) == " abc " + end + + test "block elements force newlines and indent" do + input = "
abc
" + + expected = + """ +
+ abc +
+ """ + |> String.trim_trailing() + + assert format_as_binary(input) == expected + end + + test "self-closing tags normalize whitespace" do + input = "" + assert format_as_binary(input) == "" + end + + test "empty elements" do + assert format_as_binary("
") == "
" + assert format_as_binary("") == "" + end + + test "multiple block elements" do + input = "
a
b
" + + expected = + """ +
+ a +
+
+ b +
+ """ + |> String.trim_trailing() + + assert format_as_binary(input) == expected + end + end + + describe "attributes" do + test "normalize whitespace between attributes" do + input = "
" + assert format_as_binary(input) == "
" + end + + test "boolean attributes" do + input = "" + assert format_as_binary(input) == "" + end + + test "expression attributes" do + input = "
" + assert format_as_binary(input) == "
" + end + + test "long attribute lists break and indent" do + input = + "
" + + formatted = format_as_binary(input) + assert formatted =~ "\n class=" + assert formatted =~ "\n id=" + assert formatted =~ "\n data-foo=" + end + end + + describe "whitespace sensitivity" do + test "significant whitespace in text nodes is preserved (normalized)" do + input = "
Word1 Word2
" + + expected = + """ +
+ Word1 Word2 +
+ """ + |> String.trim_trailing() + + assert format_as_binary(input) == expected + end + + test "newlines in text nodes are treated as lines" do + input = """ +
+ Line1 + Line2 +
+ """ + + # We now preserve trailing newline from heredoc + expected = "
\n Line1\n Line2\n
\n" + assert format_as_binary(input) == expected + end + + test "mixed content (text and inline tags)" do + input = "

Please click here for more.

" + + expected = + """ +

+ Please click here for more. +

+ """ + |> String.trim_trailing() + + assert format_as_binary(input) == expected + end + + test "long text nodes break and indent" do + input = + "
This is a very long text that should probably be wrapped if it exceeds eighty characters but currently it will just stay as one long line because it is returned as a single string document.
" + + formatted = format_as_binary(input) + assert String.contains?(formatted, "\n ") + # It should have broken into multiple lines + assert length(String.split(formatted, "\n")) > 3 + end + end + + describe "blocks and logic" do + test "block starts normalization" do + input = "{%if @show? }abc{/if}" + + expected = + """ + {%if @show?} + abc + {/if} + """ + |> String.trim_trailing() + + assert format_as_binary(input) == expected + end + + test "long Elixir expressions break and indent" do + input = + "
{ [:aaaaaaaaaa, :bbbbbbbbbb, :cccccccccc, :dddddddddd, :eeeeeeeeee, :ffffffffff, :gggggggggg, :hhhhhhhhhh] }
" + + formatted = format_as_binary(input) + assert formatted =~ "\n :aaaaaaaaaa," + end + + test "nested double curlies in expressions are fixed" do + input = "{%if {@show?}}abc{/if}" + + expected = + """ + {%if @show?} + abc + {/if} + """ + |> String.trim_trailing() + + assert format_as_binary(input) == expected + end + + test "deeply nested double curlies" do + input = "
{ { { @value } } }
" + + expected = + """ +
+ {@value} +
+ """ + |> String.trim_trailing() + + assert format_as_binary(input) == expected + end + + test "complex nested structure" do + input = """ + +
+ {%if @user} +

Welcome, {@user.name}!

+ {%else} + Login + {/if} +