Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for HEEx. #168

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ erl_crash.dump
/bench/snapshots
/src/slime_parser.peg
/src/slime_parser.erl
.tool-versions
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```
Comment on lines +74 to +77
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
```slim
tt
Always bring a towel.
```
```slim
tt Always bring a towel.
```

If you split the tag across multiple lines you have to use |.


```html
<tt>Always bring a towel.<tt>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be either

"<tt><Always>bring a towel.</Always></tt>"

or

tt
   | Always bring a towel.

?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right. I'll add a suggestion for that, too.

iex(1)> Slime.Renderer.precompile("tt\n  Always bring a towel")
"<tt><Always>bring a towel</Always></tt>"
iex(2)> Slime.Renderer.precompile("tt\n  | Always bring a towel") 
"<tt>Always bring a towel</tt>"

```

### Attributes

Attributes can be assigned in a similar fashion to regular HTML.
Expand Down Expand Up @@ -114,6 +127,7 @@ body#bar
<body id="bar"></body>
```

See [HEEx Support](#heex-support) for assigning attributes when rendering to HEEx.

### Code

Expand Down Expand Up @@ -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!</.greet>
```
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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion lib/slime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

Removing a formatting change to simplify the diff.

eex = file |> File.read!() |> Renderer.precompile()
EEx.function_from_string(kind, name, eex, args, opts)
end
Expand All @@ -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)
Comment on lines +82 to +83
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
eex = Renderer.precompile(source)
eex = source |> Renderer.precompile()

Removing a formatting change to simplify the diff.

EEx.function_from_string(kind, name, eex, args, opts)
end
end
Expand Down
100 changes: 68 additions & 32 deletions lib/slime/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 {"#" <> "{", "}"}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@eex_delimiters {"#" <> "{", "}"}
@eex_delimiters {"\#{", "}"}

Any reason not to write this way?

@heex_delimiters {"{", "}"}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, the values of these globals are never used, except for comparing if the passed "delimiters" parameter matches one or the other.

Given this, perhaps it would be better to pass a symbol instead? Something like complile(x, :eex) or compile(x, %{output_format: :eex})?


@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([], _delimiters), do: ""
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def compile([], _delimiters), do: ""
def compile(x), do: compile(x, @eex_delimeters)
def compile([], _delimiters), do: ""

Might (or might not!) be good to avoid breaking the public API of the module by preserving compile/1.

If this is done, all the changes to test/compiler_test.exs can be reverted.


def compile(tags) when is_list(tags) 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
Comment on lines +38 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be useful for debugging to print the HEExNode here or tell the user to look for tags that begin with :, I think.


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 =
Expand All @@ -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 <> " %>"

Expand All @@ -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
"<!--" <> compile(content) <> "-->"
def compile(%HTMLCommentNode{content: content}, delimiters) do
"<!--" <> compile(content, delimiters) <> "-->"
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}"
Expand All @@ -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
Expand All @@ -109,27 +133,34 @@ 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}"} %>"]

# 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
# String with interpolation or string concatination
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
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
# When rendering to traditional EEx
value = if safe == :eex, do: "slim__v", else: "{:safe, slim__v}"

"""
Expand All @@ -139,6 +170,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: ""

Expand Down
20 changes: 20 additions & 0 deletions lib/slime/parser/nodes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion lib/slime/parser/transform.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down
22 changes: 20 additions & 2 deletions lib/slime/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -15,7 +17,20 @@ defmodule Slime.Renderer do
def precompile(input) do
input
|> Parser.parse()
|> Compiler.compile()
|> Compiler.compile(eex_delimiters())
end

@doc """
Compile Slime template to valid EEx HTML.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Compile Slime template to valid EEx HTML.
Compile Slime template to valid HEEx HTML.


## Examples
iex> Slime.Renderer.precompile(~s(input.required type="hidden"))
"<input class=\\"required\\" type=\\"hidden\\">"
Comment on lines +27 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
iex> Slime.Renderer.precompile(~s(input.required type="hidden"))
"<input class=\\"required\\" type=\\"hidden\\">"
iex> Slime.Renderer.precompile_heex(~s(:component.required type="hidden"))
"<.component class=\\"required\\" type=\\"hidden\\">"

"""
def precompile_heex(input) do
input
|> Parser.parse()
|> Compiler.compile(heex_delimiters())
end

@doc """
Expand All @@ -25,10 +40,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()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
|> precompile()
|> precompile

Removing a formatting change to simplify the diff.

|> EEx.eval_string(bindings, opts)
end
end
Loading