Merge pull request #86 from mhanberg/0-6-components

Components API
This commit is contained in:
Mitchell Hanberg 2020-07-15 22:41:25 -04:00 committed by GitHub
commit 3da6d275a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 656 additions and 260 deletions

View file

@ -2,6 +2,12 @@
## Master
## 0.6.0-alpha.2
### Component API
Please see the README for more details regarding the Component API
## 0.6.0-alpha.1
### Generators

View file

@ -56,6 +56,64 @@ temple do
end
```
### Components
To define a component, you can create a file in your configured temple
components directory, which defaults to `lib/components`. You would
probably want to change that to be `lib/my_app_web/components` if you
are building a phoenix app.
```elixir
# config/config.exs
config :temple, :components_path, "./lib/my_app_web/components"
```
This file should be of the `.exs` extension.
You can then use this component in any other temple template.
For example, if I were to define a `flex` component, I would create a
file called `lib/my_app_web/components/flex.exs`, with the following
contents.
```elixir
div class: "flex #{@temple[:class]}", id: @id do
@children
end
```
And we could use the component like so
```elixir
flex class: "justify-between items-center", id: "arnold" do
div do: "Hi"
div do: "I'm"
div do: "Arnold"
div do: "Schwarzenegger"
end
```
We've demonstrated several features to components in this example.
We can pass assigns to our component, and access them just like we would in a normal phoenix template. If they don't match up with any assigns we passed to our component, they will be rendered as-is, and will become a normal Phoenix assign.
You can also access a special `@temple` assign. This allows you do optionally pass an assign, and not have the `@my_assign` pass through. If you didn't pass it to your component, it will evaluate to nil.
The block passed to your component can be accessed as `@children`. This allows your components to wrap a body of markup from the call site.
In order for components to trigger a recompile when they are changed, you can call `use Temple.Recompiler` in your `lib/my_app_web.ex` file, in the `view`, `live_view`, and `live_component` functions
```elixir
def view do
quote do
# ...
use Temple.Recompiler
# ...
end
end
```
### Phoenix templates
Add the templating engine to your Phoenix configuration.

View file

@ -1 +1,3 @@
use Mix.Config
import_config "#{Mix.env()}.exs"

1
config/dev.exs Normal file
View file

@ -0,0 +1 @@
use Mix.Config

3
config/test.exs Normal file
View file

@ -0,0 +1,3 @@
use Mix.Config
config :temple, components_path: "./test/support/components"

View file

@ -152,10 +152,10 @@ if Code.ensure_loaded?(Mix.Phoenix) do
end
defp copy_new_files(%Context{} = context, binding, paths) do
files = files_to_be_generated(context) |> IO.inspect(label: "FILES")
files = files_to_be_generated(context)
Mix.Phoenix.copy_from(
paths |> IO.inspect(label: "PATHS"),
paths,
"priv/templates/temple.gen.live",
binding,
files

View file

@ -122,266 +122,11 @@ defmodule Temple do
end
end
defmodule Private do
@moduledoc false
@aliases Application.get_env(:temple, :aliases, [])
@nonvoid_elements ~w[
head title style script
noscript template
body section nav article aside h1 h2 h3 h4 h5 h6
header footer address main
p pre blockquote ol ul li dl dt dd figure figcaption div
a em strong small s cite q dfn abbr data time code var samp kbd
sub sup i b u mark ruby rt rp bdi bdo span
ins del
iframe object video audio canvas
map
table caption colgroup tbody thead tfoot tr td th
form fieldset legend label button select datalist optgroup
option textarea output progress meter
details summary menuitem menu
html
]a
@nonvoid_elements_aliases Enum.map(@nonvoid_elements, fn el ->
Keyword.get(@aliases, el, el)
end)
@nonvoid_elements_lookup Enum.map(@nonvoid_elements, fn el ->
{Keyword.get(@aliases, el, el), el}
end)
@void_elements ~w[
meta link base
area br col embed hr img input keygen param source track wbr
]a
@void_elements_aliases Enum.map(@void_elements, fn el -> Keyword.get(@aliases, el, el) end)
@void_elements_lookup Enum.map(@void_elements, fn el ->
{Keyword.get(@aliases, el, el), el}
end)
def snake_to_kebab(stringable),
do:
stringable |> to_string() |> String.replace_trailing("_", "") |> String.replace("_", "-")
def kebab_to_snake(stringable),
do: stringable |> to_string() |> String.replace("-", "_")
def compile_attrs([]), do: ""
def compile_attrs([attrs]) when is_list(attrs) do
compile_attrs(attrs)
end
def compile_attrs(attrs) do
for {name, value} <- attrs, into: "" do
name = snake_to_kebab(name)
case value do
{_, _, _} = macro ->
" " <> name <> "=\"<%= " <> Macro.to_string(macro) <> " %>\""
value ->
" " <> name <> "=\"" <> to_string(value) <> "\""
end
end
end
def split_args(nil), do: {[], []}
def split_args(args) do
{do_and_else, args} =
args
|> Enum.split_with(fn
arg when is_list(arg) ->
(Keyword.keys(arg) -- [:do, :else]) |> Enum.count() == 0
_ ->
false
end)
{List.flatten(do_and_else), args}
end
def split_on_fn([{:fn, _, _} = func | rest], {args, _, args2}) do
split_on_fn(rest, {args, func, args2})
end
def split_on_fn([arg | rest], {args, nil, args2}) do
split_on_fn(rest, {[arg | args], nil, args2})
end
def split_on_fn([arg | rest], {args, func, args2}) do
split_on_fn(rest, {args, func, [arg | args2]})
end
def split_on_fn([], {args, func, args2}) do
{Enum.reverse(args), func, Enum.reverse(args2)}
end
def pop_compact?([]), do: {false, []}
def pop_compact?([args]) when is_list(args), do: pop_compact?(args)
def pop_compact?(args) do
Keyword.pop(args, :compact, false)
end
def traverse(buffer, {:__block__, _meta, block}) do
traverse(buffer, block)
end
def traverse(buffer, {name, meta, args} = macro) do
{do_and_else, args} =
args
|> split_args()
includes_fn? = args |> Enum.any?(fn x -> match?({:fn, _, _}, x) end)
case name do
{:., _, [{:__aliases__, _, [:Temple]}, name]} when name in @nonvoid_elements_aliases ->
{do_and_else, args} =
case args do
[args] ->
{do_value, args} = Keyword.pop(args, :do)
do_and_else = Keyword.put_new(do_and_else, :do, do_value)
{do_and_else, args}
_ ->
{do_and_else, args}
end
name = @nonvoid_elements_lookup[name]
{compact?, args} = pop_compact?(args)
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
unless compact?, do: Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
if compact?, do: Buffer.remove_new_line(buffer)
Buffer.put(buffer, "</#{name}>")
Buffer.put(buffer, "\n")
{:., _, [{:__aliases__, _, [:Temple]}, name]} when name in @void_elements_aliases ->
name = @void_elements_lookup[name]
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
Buffer.put(buffer, "\n")
name when name in @nonvoid_elements_aliases ->
{do_and_else, args} =
case args do
[args] ->
{do_value, args} = Keyword.pop(args, :do)
do_and_else = Keyword.put_new(do_and_else, :do, do_value)
{do_and_else, args}
_ ->
{do_and_else, args}
end
name = @nonvoid_elements_lookup[name]
{compact?, args} = pop_compact?(args)
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
unless compact?, do: Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
if compact?, do: Buffer.remove_new_line(buffer)
Buffer.put(buffer, "</#{name}>")
Buffer.put(buffer, "\n")
name when name in @void_elements_aliases ->
name = @void_elements_lookup[name]
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
Buffer.put(buffer, "\n")
name when includes_fn? ->
{args, func_arg, args2} = split_on_fn(args, {[], nil, []})
{func, _, [{arrow, _, [[{arg, _, _}], block]}]} = func_arg
Buffer.put(
buffer,
"<%= " <>
to_string(name) <>
" " <>
(Enum.map(args, &Macro.to_string(&1)) |> Enum.join(", ")) <>
", " <>
to_string(func) <> " " <> to_string(arg) <> " " <> to_string(arrow) <> " %>"
)
Buffer.put(buffer, "\n")
traverse(buffer, block)
if Enum.any?(args2) do
Buffer.put(
buffer,
"<% end, " <>
(Enum.map(args2, fn arg -> Macro.to_string(arg) end)
|> Enum.join(", ")) <> " %>"
)
Buffer.put(buffer, "\n")
else
Buffer.put(buffer, "<% end %>")
Buffer.put(buffer, "\n")
end
name when name in [:for, :if, :unless] ->
Buffer.put(buffer, "<%= " <> Macro.to_string({name, meta, args}) <> " do %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
if Keyword.has_key?(do_and_else, :else) do
Buffer.put(buffer, "<% else %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:else])
end
Buffer.put(buffer, "<% end %>")
Buffer.put(buffer, "\n")
name when name in [:=] ->
Buffer.put(buffer, "<% " <> Macro.to_string(macro) <> " %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
_ ->
Buffer.put(buffer, "<%= " <> Macro.to_string(macro) <> " %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
end
end
def traverse(buffer, [first | rest]) do
traverse(buffer, first)
traverse(buffer, rest)
end
def traverse(buffer, text) when is_binary(text) do
Buffer.put(buffer, text)
Buffer.put(buffer, "\n")
end
def traverse(_buffer, arg) when arg in [nil, []] do
nil
end
end
defmacro temple([do: block] = _block) do
{:ok, buffer} = Buffer.start_link()
buffer
|> Temple.Private.traverse(block)
|> Temple.Parser.Private.traverse(block)
markup = Buffer.get(buffer)
@ -399,7 +144,7 @@ defmodule Temple do
{:ok, buffer} = Buffer.start_link()
buffer
|> Temple.Private.traverse(unquote(block))
|> Temple.Parser.Private.traverse(unquote(block))
markup = Buffer.get(buffer)
@ -413,7 +158,7 @@ defmodule Temple do
{:ok, buffer} = Buffer.start_link()
buffer
|> Temple.Private.traverse(block)
|> Temple.Parser.Private.traverse(block)
markup = Buffer.get(buffer)

455
lib/temple/parser.ex Normal file
View file

@ -0,0 +1,455 @@
defmodule Temple.Parser do
alias Temple.Buffer
@components_path Application.get_env(:temple, :components_path, "./lib/components")
@aliases Application.get_env(:temple, :aliases, [])
@nonvoid_elements ~w[
head title style script
noscript template
body section nav article aside h1 h2 h3 h4 h5 h6
header footer address main
p pre blockquote ol ul li dl dt dd figure figcaption div
a em strong small s cite q dfn abbr data time code var samp kbd
sub sup i b u mark ruby rt rp bdi bdo span
ins del
iframe object video audio canvas
map
table caption colgroup tbody thead tfoot tr td th
form fieldset legend label button select datalist optgroup
option textarea output progress meter
details summary menuitem menu
html
]a
@nonvoid_elements_aliases Enum.map(@nonvoid_elements, fn el ->
Keyword.get(@aliases, el, el)
end)
@nonvoid_elements_lookup Enum.map(@nonvoid_elements, fn el ->
{Keyword.get(@aliases, el, el), el}
end)
@void_elements ~w[
meta link base
area br col embed hr img input keygen param source track wbr
]a
@void_elements_aliases Enum.map(@void_elements, fn el -> Keyword.get(@aliases, el, el) end)
@void_elements_lookup Enum.map(@void_elements, fn el ->
{Keyword.get(@aliases, el, el), el}
end)
defmodule Private do
@moduledoc false
def snake_to_kebab(stringable),
do:
stringable |> to_string() |> String.replace_trailing("_", "") |> String.replace("_", "-")
def kebab_to_snake(stringable),
do: stringable |> to_string() |> String.replace("-", "_")
def compile_attrs([]), do: ""
def compile_attrs([attrs]) when is_list(attrs) do
compile_attrs(attrs)
end
def compile_attrs(attrs) do
for {name, value} <- attrs, into: "" do
name = snake_to_kebab(name)
case value do
{_, _, _} = macro ->
" " <> name <> "=\"<%= " <> Macro.to_string(macro) <> " %>\""
value ->
" " <> name <> "=\"" <> to_string(value) <> "\""
end
end
end
def split_args(nil), do: {[], []}
def split_args(args) do
{do_and_else, args} =
args
|> Enum.split_with(fn
arg when is_list(arg) ->
(Keyword.keys(arg) -- [:do, :else]) |> Enum.count() == 0
_ ->
false
end)
{List.flatten(do_and_else), args}
end
def split_on_fn([{:fn, _, _} = func | rest], {args, _, args2}) do
split_on_fn(rest, {args, func, args2})
end
def split_on_fn([arg | rest], {args, nil, args2}) do
split_on_fn(rest, {[arg | args], nil, args2})
end
def split_on_fn([arg | rest], {args, func, args2}) do
split_on_fn(rest, {args, func, [arg | args2]})
end
def split_on_fn([], {args, func, args2}) do
{Enum.reverse(args), func, Enum.reverse(args2)}
end
def pop_compact?([]), do: {false, []}
def pop_compact?([args]) when is_list(args), do: pop_compact?(args)
def pop_compact?(args) do
Keyword.pop(args, :compact, false)
end
def traverse(buffer, {:__block__, _meta, block}) do
traverse(buffer, block)
end
def traverse(buffer, {_name, _meta, _args} = original_macro) do
Temple.Parser.parsers()
|> Enum.reduce_while(original_macro, fn parser, macro ->
with true <- parser.applicable?.(macro),
:ok <- parser.parse.(macro, buffer) do
{:halt, macro}
else
{:component_applied, adjusted_macro} ->
traverse(buffer, adjusted_macro)
{:halt, adjusted_macro}
false ->
{:cont, macro}
end
end)
end
def traverse(buffer, [first | rest]) do
traverse(buffer, first)
traverse(buffer, rest)
end
def traverse(buffer, text) when is_binary(text) do
Buffer.put(buffer, text)
Buffer.put(buffer, "\n")
:ok
end
def traverse(_buffer, arg) when arg in [nil, []] do
:ok
end
end
def parsers(),
do: [
%{
name: :temple_namespace_nonvoid,
applicable?: fn {name, _meta, _args} ->
try do
{:., _, [{:__aliases__, _, [:Temple]}, name]} = name
name in @nonvoid_elements_aliases
rescue
MatchError ->
false
end
end,
parse: fn {name, _meta, args}, buffer ->
import Temple.Parser.Private
{:., _, [{:__aliases__, _, [:Temple]}, name]} = name
{do_and_else, args} =
args
|> split_args()
{do_and_else, args} =
case args do
[args] ->
{do_value, args} = Keyword.pop(args, :do)
do_and_else = Keyword.put_new(do_and_else, :do, do_value)
{do_and_else, args}
_ ->
{do_and_else, args}
end
name = @nonvoid_elements_lookup[name]
{compact?, args} = pop_compact?(args)
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
unless compact?, do: Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
if compact?, do: Buffer.remove_new_line(buffer)
Buffer.put(buffer, "</#{name}>")
Buffer.put(buffer, "\n")
end
},
%{
name: :temple_namespace_void,
applicable?: fn {name, _meta, _args} ->
try do
{:., _, [{:__aliases__, _, [:Temple]}, name]} = name
name in @void_elements_aliases
rescue
MatchError ->
false
end
end,
parse: fn {name, _, args}, buffer ->
import Temple.Parser.Private
{:., _, [{:__aliases__, _, [:Temple]}, name]} = name
{_do_and_else, args} =
args
|> split_args()
name = @void_elements_lookup[name]
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
Buffer.put(buffer, "\n")
end
},
%{
name: :components,
applicable?: fn {name, meta, _} ->
try do
!meta[:temple_component_applied] &&
File.exists?(Path.join([@components_path, "#{name}.exs"]))
rescue
_ ->
false
end
end,
parse: fn {name, _meta, args}, _buffer ->
import Temple.Parser.Private
{assigns, children} =
case args do
[assigns, [do: block]] ->
{assigns, block}
[[do: block]] ->
{nil, block}
[assigns] ->
{assigns, nil}
_ ->
{nil, nil}
end
ast =
File.read!(Path.join([@components_path, "#{name}.exs"]))
|> Code.string_to_quoted!()
{name, meta, args} =
ast
|> Macro.prewalk(fn
{:@, _, [{:children, _, _}]} ->
children
{:@, _, [{:temple, _, _}]} ->
assigns
{:@, _, [{name, _, _}]} = node ->
if !is_nil(assigns) && name in Keyword.keys(assigns) do
Keyword.get(assigns, name, nil)
else
node
end
node ->
node
end)
ast =
if Enum.any?(
[
@nonvoid_elements,
@nonvoid_elements_aliases,
@void_elements,
@void_elements_aliases
],
fn elements -> name in elements end
) do
{name, Keyword.put(meta, :temple_component_applied, true), args}
else
{name, meta, args}
end
{:component_applied, ast}
end
},
%{
name: :nonvoid_elements_aliases,
applicable?: fn {name, _, _} ->
name in @nonvoid_elements_aliases
end,
parse: fn {name, _, args}, buffer ->
import Temple.Parser.Private
{do_and_else, args} =
args
|> split_args()
{do_and_else, args} =
case args do
[args] ->
{do_value, args} = Keyword.pop(args, :do)
do_and_else = Keyword.put_new(do_and_else, :do, do_value)
{do_and_else, args}
_ ->
{do_and_else, args}
end
name = @nonvoid_elements_lookup[name]
{compact?, args} = pop_compact?(args)
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
unless compact?, do: Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
if compact?, do: Buffer.remove_new_line(buffer)
Buffer.put(buffer, "</#{name}>")
Buffer.put(buffer, "\n")
end
},
%{
name: :void_elements_aliases,
applicable?: fn {name, _, _} ->
name in @void_elements_aliases
end,
parse: fn {name, _, args}, buffer ->
import Temple.Parser.Private
{_do_and_else, args} =
args
|> split_args()
name = @void_elements_lookup[name]
Buffer.put(buffer, "<#{name}#{compile_attrs(args)}>")
Buffer.put(buffer, "\n")
end
},
%{
name: :anonymous_functions,
applicable?: fn {_, _, args} ->
import Temple.Parser.Private, only: [split_args: 1]
args |> split_args() |> elem(1) |> Enum.any?(fn x -> match?({:fn, _, _}, x) end)
end,
parse: fn {name, _, args}, buffer ->
import Temple.Parser.Private
{_do_and_else, args} =
args
|> split_args()
{args, func_arg, args2} = split_on_fn(args, {[], nil, []})
{func, _, [{arrow, _, [[{arg, _, _}], block]}]} = func_arg
Buffer.put(
buffer,
"<%= " <>
to_string(name) <>
" " <>
(Enum.map(args, &Macro.to_string(&1)) |> Enum.join(", ")) <>
", " <>
to_string(func) <> " " <> to_string(arg) <> " " <> to_string(arrow) <> " %>"
)
Buffer.put(buffer, "\n")
traverse(buffer, block)
if Enum.any?(args2) do
Buffer.put(
buffer,
"<% end, " <>
(Enum.map(args2, fn arg -> Macro.to_string(arg) end)
|> Enum.join(", ")) <> " %>"
)
Buffer.put(buffer, "\n")
else
Buffer.put(buffer, "<% end %>")
Buffer.put(buffer, "\n")
end
end
},
%{
name: :for_if_unless,
applicable?: fn {name, _, _} ->
name in [:for, :if, :unless]
end,
parse: fn {name, meta, args}, buffer ->
import Temple.Parser.Private
{do_and_else, args} =
args
|> split_args()
Buffer.put(buffer, "<%= " <> Macro.to_string({name, meta, args}) <> " do %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
if Keyword.has_key?(do_and_else, :else) do
Buffer.put(buffer, "<% else %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:else])
end
Buffer.put(buffer, "<% end %>")
Buffer.put(buffer, "\n")
end
},
%{
name: :match,
applicable?: fn {name, _, _} ->
name in [:=]
end,
parse: fn {_, _, args} = macro, buffer ->
import Temple.Parser.Private
{do_and_else, _args} =
args
|> split_args()
Buffer.put(buffer, "<% " <> Macro.to_string(macro) <> " %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
end
},
%{
name: :default,
applicable?: fn _ -> true end,
parse: fn {_, _, args} = macro, buffer ->
import Temple.Parser.Private
{do_and_else, _args} =
args
|> split_args()
Buffer.put(buffer, "<%= " <> Macro.to_string(macro) <> " %>")
Buffer.put(buffer, "\n")
traverse(buffer, do_and_else[:do])
end
}
]
end

15
lib/temple/recompiler.ex Normal file
View file

@ -0,0 +1,15 @@
defmodule Temple.Recompiler do
defmacro __using__(_) do
quote do
component_path = Application.get_env(:temple, :components_path)
for f <- File.ls!(component_path),
do:
Module.put_attribute(
__MODULE__,
:external_resource,
Path.join(component_path, f)
)
end
end
end

16
test/partial_test.exs Normal file
View file

@ -0,0 +1,16 @@
defmodule PartialTest do
use ExUnit.Case, async: true
use Temple
use Temple.Support.Utils
test "can correctly redefine elements" do
result =
temple do
section do
"Howdy!"
end
end
assert result == ~s{<section class="foo!">Howdy!</section>}
end
end

View file

@ -0,0 +1,3 @@
div class: @assign do
@children
end

View file

@ -0,0 +1,3 @@
div class: @class do
@children
end

View file

@ -0,0 +1,3 @@
div class: @temple[:class] do
@children
end

View file

@ -0,0 +1,3 @@
div id: "inner", outer_id: @outer_id do
@children
end

View file

@ -0,0 +1,3 @@
inner outer_id: "from-outer" do
@children
end

View file

@ -0,0 +1,3 @@
section class: "foo!" do
@children
end

View file

@ -265,4 +265,81 @@ defmodule TempleTest do
assert result == ~s{<p>Bob</p>\n<p><%= foo %></p>}
end
test "inlines function components" do
result =
temple do
div class: "font-bold" do
"Hello, world"
end
component do
"I'm a component!"
end
end
assert result ==
~s{<div class="font-bold">Hello, world</div><div class="<%= @assign %>">I'm a component!</div>}
end
test "function components can accept local assigns" do
result =
temple do
div class: "font-bold" do
"Hello, world"
end
component2 class: "bg-red" do
"I'm a component!"
end
end
assert result ==
~s{<div class="font-bold">Hello, world</div><div class="bg-red">I'm a component!</div>}
end
test "function components can accept local assigns that are variables" do
result =
temple do
div class: "font-bold" do
"Hello, world"
end
class = "bg-red"
component2 class: class do
"I'm a component!"
end
end
assert result ==
~s{<div class="font-bold">Hello, world</div><% class = "bg-red" %><div class="<%= class %>">I'm a component!</div>}
end
test "function components can use other components" do
result =
temple do
outer do
"outer!"
end
inner do
"inner!"
end
end
assert result ==
~s{<div id="inner" outer-id="from-outer">outer!</div><div id="inner" outer-id="<%= @outer_id %>">inner!</div>}
end
test "@temple should be available in any component" do
result =
temple do
has_temple class: "boom" do
"yay!"
end
end
assert result == ~s{<div class="<%= [class: "boom"][:class] %>">yay!</div>}
end
end