From df65f1fa729919f9882b7ef4d878a884342b409e Mon Sep 17 00:00:00 2001 From: Jonathan Yankovich Date: Sat, 6 Nov 2021 10:08:29 -0500 Subject: [PATCH] Add support for HEEx. Co-Authored-By: Topher Hunt --- .tool-versions | 2 + README.md | 39 +++++++++++- lib/slime.ex | 10 +++- lib/slime/compiler.ex | 109 ++++++++++++++++++++++++---------- lib/slime/parser/nodes.ex | 20 +++++++ lib/slime/parser/transform.ex | 9 ++- lib/slime/renderer.ex | 23 ++++++- src/slime_parser.peg.eex | 5 +- test/compiler_test.exs | 20 +++++-- test/rendering/heex_test.exs | 59 ++++++++++++++++++ 10 files changed, 253 insertions(+), 43 deletions(-) create mode 100644 .tool-versions create mode 100644 test/rendering/heex_test.exs diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..b129215 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.12.2 +erlang 24.0.2 diff --git a/README.md b/README.md index dfcc952..fe3bd78 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,19 @@ Slime.render(source, site_title: "Website Title") ## Reference +### Tags + +Starting a line with a string followed by a space will create an html tag, as follows: + +```slim +tt + Always bring a towel. +``` + +```html +Always bring a towel. +``` + ### Attributes Attributes can be assigned in a similar fashion to regular HTML. @@ -114,6 +127,7 @@ body#bar ``` +See [HEEx Support](#heex-support) for assigning attributes when rendering to HEEx. ### Code @@ -277,6 +291,28 @@ the library after you have added new engines. You can do this by: mix deps.compile slime --force ``` +## HEEx Support + +To output HEEx instead of HTML, see [`phoenix_slime`](https://github.com/slime-lang/phoenix_slime). This will cause slime to emit "html aware" HEEx with two differences from conventional HTML: + +- Attribute values will be wrapped in curley-braces (`{}`) instead of escaped EEx (`#{}`): + +- HTML Components will be prefixed with a dot. To render an HTML Component, prefix the component name with a colon (`:`). This will tell slime to render these html tags with a dot-prefix (`.`). + +For example, + +```slim +:greet user=@current_user.name + | Hello there! +``` +would create the following output: + +``` +<.greet user={@current_user.name}>Hello there! +``` +When using slime with Phoenix, the `phoenix_slime` package will call `precompile_heex/2` and pass the resulting valid HEEx to [`EEx`](https://hexdocs.pm/eex/EEx.html) with [`Phoenix.LiveView.HTMLEngine`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.HTMLEngine.html#handle_text/3) as the `engine:` option. This will produce the final html. + + ## Precompilation Templates can be compiled into module functions like EEx templates, using @@ -285,7 +321,7 @@ functions `Slime.function_from_file/5` and To use slime templates (and Slime) with [Phoenix][phoenix], please see -[PhoenixSlim][phoenix-slime]. +[PhoenixSlime][phoenix-slime]. [phoenix]: http://www.phoenixframework.org/ [phoenix-slime]: https://github.com/slime-lang/phoenix_slime @@ -319,6 +355,7 @@ where Ruby Slim would do Note the `do` and the initial `=`, because we render the return value of the conditional as a whole. +Slime also adds support for HEEx. See the section on [HEEx Support](#heex-support). ## Debugging diff --git a/lib/slime.ex b/lib/slime.ex index f0a6fd4..9c88bd6 100644 --- a/lib/slime.ex +++ b/lib/slime.ex @@ -46,10 +46,14 @@ defmodule Slime do # iex Sample.sample(1, 2) #=> "3" + + Note: A HEEx-aware version of function_from_file/5 was not included because it would require importing + Phoenix.LiveView.HTMLEngine, creating a dependency on Phoenix. """ defmacro function_from_file(kind, name, file, args \\ [], opts \\ []) do quote bind_quoted: binding() do require EEx + eex = file |> File.read!() |> Renderer.precompile() EEx.function_from_string(kind, name, eex, args, opts) end @@ -68,11 +72,15 @@ defmodule Slime do ...> end iex> Sample.sample(1, 2) "3" + + Note: A HEEx-aware version of function_from_string/5 was not included because it would require importing + Phoenix.LiveView.HTMLEngine, creating a dependency on Phoenix. """ defmacro function_from_string(kind, name, source, args \\ [], opts \\ []) do quote bind_quoted: binding() do require EEx - eex = source |> Renderer.precompile() + + eex = Renderer.precompile(source) EEx.function_from_string(kind, name, eex, args, opts) end end diff --git a/lib/slime/compiler.ex b/lib/slime/compiler.ex index 7be6106..e2cab2d 100644 --- a/lib/slime/compiler.ex +++ b/lib/slime/compiler.ex @@ -5,27 +5,51 @@ defmodule Slime.Compiler do alias Slime.Doctype - alias Slime.Parser.Nodes.{DoctypeNode, EExNode, HTMLCommentNode, HTMLNode, InlineHTMLNode, VerbatimTextNode} + alias Slime.Parser.Nodes.{DoctypeNode, EExNode, HEExNode, HTMLCommentNode, HTMLNode, InlineHTMLNode, VerbatimTextNode} + + alias Slime.TemplateSyntaxError + + @eex_delimiters {"#" <> "{", "}"} + @heex_delimiters {"{", "}"} @void_elements ~w( area br col doctype embed hr img input link meta base param keygen source menuitem track wbr ) - def compile([]), do: "" + def eex_delimiters, do: @eex_delimiters + def heex_delimiters, do: @heex_delimiters - def compile(tags) when is_list(tags) do + def compile([], _delimiters), do: "" + + def compile(tags, delimiters) when is_list(tags) do tags - |> Enum.map(&compile(&1)) + |> Enum.map(&compile(&1, delimiters)) |> Enum.join() |> String.replace("\r", "") end - def compile(%DoctypeNode{name: name}), do: Doctype.for(name) - def compile(%VerbatimTextNode{content: content}), do: compile(content) + def compile(%DoctypeNode{name: name}, _delimiters), do: Doctype.for(name) + def compile(%VerbatimTextNode{content: content}, delimiters), do: compile(content, delimiters) + + def compile(%HEExNode{}, @eex_delimiters) do + # Raise an error if the user generates a HEEx node (by using a :) but the target is EEx + + raise TemplateSyntaxError, + line: 0, + message: "I found a HEEx component, but this is not compiling to a HEEx file", + line_number: 0, + column: 0 + end + + def compile(%HEExNode{} = tag, @heex_delimiters) do + # Pass the HEExNode through to HTMLNode since it behaves identically + tag = Map.put(tag, :__struct__, HTMLNode) + compile(tag, @heex_delimiters) + end - def compile(%HTMLNode{name: name, spaces: spaces} = tag) do - attrs = Enum.map(tag.attributes, &render_attribute/1) + def compile(%HTMLNode{name: name, spaces: spaces} = tag, delimiters) do + attrs = Enum.map(tag.attributes, &render_attribute(&1, delimiters)) tag_head = Enum.join([name | attrs]) body = @@ -37,13 +61,13 @@ defmodule Slime.Compiler do "<" <> tag_head <> ">" :otherwise -> - "<" <> tag_head <> ">" <> compile(tag.children) <> " name <> ">" + "<" <> tag_head <> ">" <> compile(tag.children, delimiters) <> " name <> ">" end leading_space(spaces) <> body <> trailing_space(spaces) end - def compile(%EExNode{content: code, spaces: spaces, output: output} = eex) do + def compile(%EExNode{content: code, spaces: spaces, output: output} = eex, delimiters) do code = if eex.safe?, do: "{:safe, " <> code <> "}", else: code opening = if(output, do: "<%= ", else: "<% ") <> code <> " %>" @@ -54,30 +78,30 @@ defmodule Slime.Compiler do "" end - body = opening <> compile(eex.children) <> closing + body = opening <> compile(eex.children, delimiters) <> closing leading_space(spaces) <> body <> trailing_space(spaces) end - def compile(%InlineHTMLNode{content: content, children: children}) do - compile(content) <> compile(children) + def compile(%InlineHTMLNode{content: content, children: children}, delimiters) do + compile(content, delimiters) <> compile(children, delimiters) end - def compile(%HTMLCommentNode{content: content}) do - "" + def compile(%HTMLCommentNode{content: content}, delimiters) do + "" end - def compile({:eex, eex}), do: "<%= " <> eex <> "%>" - def compile({:safe_eex, eex}), do: "<%= {:safe, " <> eex <> "} %>" - def compile(raw), do: raw + def compile({:eex, eex}, _delimiter), do: "<%= " <> eex <> "%>" + def compile({:safe_eex, eex}, _delimiter), do: "<%= {:safe, " <> eex <> "} %>" + def compile(raw, _delimiter), do: raw @spec hide_dialyzer_spec(any) :: any def hide_dialyzer_spec(input), do: input - defp render_attribute({_, []}), do: "" - defp render_attribute({_, ""}), do: "" + defp render_attribute({_, []}, _delimiters), do: "" + defp render_attribute({_, ""}, _delimiters), do: "" - defp render_attribute({name, {safe_eex, content}}) do + defp render_attribute({name, {safe_eex, content}}, delimiters) do case content do "true" -> " #{name}" @@ -90,11 +114,11 @@ defmodule Slime.Compiler do _ -> {:ok, quoted_content} = Code.string_to_quoted(content) - render_attribute_code(name, content, quoted_content, safe_eex) + render_attribute_code(name, content, quoted_content, safe_eex, delimiters) end end - defp render_attribute({name, value}) do + defp render_attribute({name, value}, _delimiters) do if value == true do " #{name}" else @@ -109,27 +133,45 @@ defmodule Slime.Compiler do end end - defp render_attribute_code(name, _content, quoted, _safe) + defp render_attribute_code(name, _content, quoted, _safe, _delimiters) 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 + defp render_attribute_code(name, _content, quoted, _, _delimiters) when is_list(quoted) do quoted |> Enum.map_join(" ", &Kernel.to_string/1) |> (&~s[ #{name}="#{&1}"]).() end - defp render_attribute_code(name, _content, quoted, :eex) when is_binary(quoted), do: ~s[ #{name}="#{quoted}"] + defp render_attribute_code(name, _content, quoted, :eex, _delimiters) when is_binary(quoted), + do: ~s[ #{name}="#{quoted}"] - defp render_attribute_code(name, _content, quoted, _) when is_binary(quoted), + defp render_attribute_code(name, _content, quoted, _, _delimiters) when is_binary(quoted), do: ~s[ #{name}="<%= {:safe, "#{quoted}"} %>"] + # # Topher and Jonathan are writing elixir here + # defp render_attribute_code(name, content, {op, _, _}, _delimiters) when op in [:<<>>, :<>] do + # # was: ~s[ #{name}="<%= #{content} %>"] + # IO.inspect("WE DID IT!") + # ~s[ #{name}="{#{content}}"] + # end + # NOTE: string with interpolation or strings concatination - defp render_attribute_code(name, content, {op, _, _}, safe) when op in [:<<>>, :<>] do - value = if safe == :eex, do: content, else: "{:safe, #{content}}" - ~s[ #{name}="<%= #{value} %>"] + defp render_attribute_code(name, content, {op, _, _}, safe, @heex_delimiters) when op in [:<<>>, :<>] do + # IO.inspect op, label: "heex_delimiters <<>>" + expression = if safe == :eex, do: content, else: "{:safe, #{content}}" + ~s[ #{name}={#{expression}}] + end + + defp render_attribute_code(name, content, {op, _, _}, safe, @eex_delimiters) when op in [:<<>>, :<>] do + # IO.inspect op, label: "eex_delimiters <<>>" + expression = if safe == :eex, do: content, else: "{:safe, #{content}}" + ~s[ #{name}="<%= #{expression} %>"] end - defp render_attribute_code(name, content, _, safe) do + defp render_attribute_code(name, content, _, safe, @eex_delimiters) do + # IO.inspect "EEx" + + # When rendering to traditional EEx value = if safe == :eex, do: "slim__v", else: "{:safe, slim__v}" """ @@ -139,6 +181,11 @@ defmodule Slime.Compiler do """ end + defp render_attribute_code(name, content, _, _safe, @heex_delimiters) do + # When rendering to html-aware HEEx + ~s[ #{name}={#{content}}] + end + defp leading_space(%{leading: true}), do: " " defp leading_space(_), do: "" diff --git a/lib/slime/parser/nodes.ex b/lib/slime/parser/nodes.ex index 0c650d4..09e52d9 100644 --- a/lib/slime/parser/nodes.ex +++ b/lib/slime/parser/nodes.ex @@ -38,6 +38,26 @@ defmodule Slime.Parser.Nodes do safe?: false end + defmodule HEExNode do + @moduledoc """ + An HTML node that represents a HEEx function component. + + * :name — function component (tag) name, + * :attributes — a list of {"name", :v} tuples, where :v is + either a string or an {:eex, "content"} tuple, + * :spaces — tag whitespace, represented as a keyword list of boolean + values for :leading and :trailing, + * :closed — the presence of a trailing "/", which explicitly closes the tag, + * :children — a list of nodes. + """ + + defstruct name: "", + attributes: [], + spaces: %{}, + closed: false, + children: [] + end + defmodule VerbatimTextNode do @moduledoc """ A verbatim text node. diff --git a/lib/slime/parser/transform.ex b/lib/slime/parser/transform.ex index e882e7a..3bb1246 100644 --- a/lib/slime/parser/transform.ex +++ b/lib/slime/parser/transform.ex @@ -8,7 +8,7 @@ defmodule Slime.Parser.Transform do import Slime.Parser.Preprocessor, only: [indent_size: 1] alias Slime.Parser.{AttributesKeyword, EmbeddedEngine, TextBlock} - alias Slime.Parser.Nodes.{DoctypeNode, EExNode, HTMLCommentNode, HTMLNode, InlineHTMLNode, VerbatimTextNode} + alias Slime.Parser.Nodes.{DoctypeNode, EExNode, HEExNode, HTMLCommentNode, HTMLNode, InlineHTMLNode, VerbatimTextNode} alias Slime.TemplateSyntaxError @@ -214,6 +214,13 @@ defmodule Slime.Parser.Transform do %EExNode{content: to_string(content), output: true, safe?: safe == "="} end + def transform(:function_component, [":", name, _space, content], _index) do + {attributes, children, false} = content + # Match on brief function components, e.g. ".city" and explicit, e.g. "MyApp.city" + leading_dot = if "." in name, do: "", else: "." + %HEExNode{name: "#{leading_dot}#{name}", attributes: attributes, children: children} + end + def transform(:tag_spaces, input, _index) do leading = input[:leading] trailing = input[:trailing] diff --git a/lib/slime/renderer.ex b/lib/slime/renderer.ex index 7942e02..0f449d3 100644 --- a/lib/slime/renderer.ex +++ b/lib/slime/renderer.ex @@ -5,6 +5,8 @@ defmodule Slime.Renderer do alias Slime.Compiler alias Slime.Parser + import Compiler, only: [eex_delimiters: 0, heex_delimiters: 0] + @doc """ Compile Slime template to valid EEx HTML. @@ -15,7 +17,21 @@ defmodule Slime.Renderer do def precompile(input) do input |> Parser.parse() - |> Compiler.compile() + # |> OriginalCompiler.compile() + |> Compiler.compile(eex_delimiters()) + end + + @doc """ + Compile Slime template to valid EEx HTML. + + ## Examples + iex> Slime.Renderer.precompile(~s(input.required type="hidden")) + "" + """ + def precompile_heex(input) do + input + |> Parser.parse() + |> Compiler.compile(heex_delimiters()) end @doc """ @@ -25,10 +41,13 @@ defmodule Slime.Renderer do Note that this method of rendering is substantially slower than rendering precompiled templates created with Slime.function_from_file/5 and Slime.function_from_string/5. + + Note: A HEEx-aware version of render/4 was not included because it would require importing + Phoenix.LiveView.HTMLEngine, creating a dependency on Phoenix. """ def render(slime, bindings \\ [], opts \\ []) do slime - |> precompile + |> precompile() |> EEx.eval_string(bindings, opts) end end diff --git a/src/slime_parser.peg.eex b/src/slime_parser.peg.eex index 0700cf2..74edcd5 100644 --- a/src/slime_parser.peg.eex +++ b/src/slime_parser.peg.eex @@ -3,7 +3,7 @@ document <- (space? crlf)* doctype? tags? eof; doctype <- space? 'doctype' space name:(!eol .)+ eol (space? crlf)*; tag <- comment / verbatim_text / tag_item; -tag_item <- space? (embedded_engine / inline_html / code / slime_tag); +tag_item <- space? (embedded_engine / inline_html / code / function_component / slime_tag); tags <- (tag crlf*)+; nested_tags <- crlf+ indent tags dedent; @@ -24,6 +24,8 @@ inline_text <- !eol text_block; dynamic_content <- '=' '='? space? (!eol .)+; +function_component <- ':' (function_component_name) space? tag_attributes_and_content; + code <- output:('=' '='? tag_spaces? / '-') space? code:code_lines children:nested_tags? optional_else:code_else_condition?; code_else_condition <- crlf* space? '-' space? 'else' children:nested_tags?; @@ -96,6 +98,7 @@ embedded_engine_lines <- indented_text_line (crlf indented_text_line)*; indented_text_line <- space? text_item*; tag_name <- [a-zA-Z0-9_-]+; +function_component_name <- [a-zA-Z0-9._-]+; shortcut_value <- ([:/]? [a-zA-Z0-9_-])+; attribute_name <- [a-zA-Z0-9._@:-]+; space <- [ \t]+; diff --git a/test/compiler_test.exs b/test/compiler_test.exs index c99ca91..0ffd763 100644 --- a/test/compiler_test.exs +++ b/test/compiler_test.exs @@ -5,10 +5,18 @@ defmodule CompilerTest do alias Slime.Parser.Nodes.{DoctypeNode, EExNode, HTMLNode, VerbatimTextNode} + defp compile(tree) do + Compiler.compile(tree, Compiler.eex_delimiters()) + end + + # defp compile(tree) do + # Slime.OriginalCompiler.compile(tree) + # end + describe "compile/1" do test "renders doctype" do tree = [%DoctypeNode{name: "html"}] - assert Compiler.compile(tree) == "" + assert compile(tree) == "" end test "renders eex attributes" do @@ -26,7 +34,7 @@ defmodule CompilerTest do """ |> String.replace("\n", "") - assert Compiler.compile(tree) == expected + assert compile(tree) == expected end test "renders eex" do @@ -38,7 +46,7 @@ defmodule CompilerTest do ] expected = ~s(<%= number_input f, :amount, class: "js-donation-amount" %>) - assert Compiler.compile(tree) == expected + assert compile(tree) == expected end test "inserts 'end' tokens for do blocks and anonymous functions" do @@ -62,7 +70,7 @@ defmodule CompilerTest do """ |> String.replace("\n", "") - assert Compiler.compile(tree) == expected + assert compile(tree) == expected end test "does not insert 'end' tokens for inline blocks" do @@ -78,7 +86,7 @@ defmodule CompilerTest do """ |> String.replace("\n", "") - assert Compiler.compile(tree) == expected + assert compile(tree) == expected end test "renders boolean attributes" do @@ -94,7 +102,7 @@ defmodule CompilerTest do """ |> String.replace("\n", "") - assert Compiler.compile(tree) == expected + assert compile(tree) == expected end end end diff --git a/test/rendering/heex_test.exs b/test/rendering/heex_test.exs new file mode 100644 index 0000000..fccf891 --- /dev/null +++ b/test/rendering/heex_test.exs @@ -0,0 +1,59 @@ +defmodule FunctionComponentTest do + use ExUnit.Case, async: true + + import Slime.Renderer, only: [precompile_heex: 1] + + test "function components work with scalar attributes" do + slime = ":my_function foo=1" + heex = ~s(<.my_function foo="1">) + assert precompile_heex(slime) == heex + end + + test "function components work with assigns" do + slime = ":my_function foo=one" + heex = ~s(<.my_function foo={one}>) + assert precompile_heex(slime) == heex + end + + test "function components work with inner html" do + slime = ~s""" + :my_function + div + | Inner Html + """ + + heex = ~s(<.my_function>
Inner Html
) + + assert precompile_heex(slime) == heex + end + + test "function components work with inner html and assigns" do + slime = ~s""" + :my_function foo=bar + div + | Inner Html + """ + + heex = ~s(<.my_function foo={bar}>
Inner Html
) + + assert precompile_heex(slime) == heex + end + + test "assigns work in interpolated string expressions" do + slime = ~s(div id="user_\#{id}") + heex = ~s(
) + assert precompile_heex(slime) == heex + end + + test "function components work when called with full module name" do + slime = ":MyApp.module.city" + heex = "" + assert precompile_heex(slime) == heex + end + + test "function components work with assigns when called with full module name" do + slime = ":MyApp.module.city name=city_name" + heex = "" + assert precompile_heex(slime) == heex + end +end