418 lines
12 KiB
Elixir
418 lines
12 KiB
Elixir
defmodule Phoenix.HTML do
|
|
@moduledoc """
|
|
Building blocks for working with HTML in Phoenix.
|
|
|
|
This library provides three main functionalities:
|
|
|
|
* HTML safety
|
|
* Form abstractions
|
|
* A tiny JavaScript library to enhance applications
|
|
|
|
## HTML safety
|
|
|
|
One of the main responsibilities of this package is to
|
|
provide convenience functions for escaping and marking
|
|
HTML code as safe.
|
|
|
|
By default, data output in templates is not considered
|
|
safe:
|
|
|
|
```heex
|
|
<%= "<hello>" %>
|
|
```
|
|
|
|
will be shown as:
|
|
|
|
```html
|
|
<hello>
|
|
```
|
|
|
|
User data or data coming from the database is almost never
|
|
considered safe. However, in some cases, you may want to tag
|
|
it as safe and show its "raw" contents:
|
|
|
|
```heex
|
|
<%= raw "<hello>" %>
|
|
```
|
|
|
|
## Form handling
|
|
|
|
See `Phoenix.HTML.Form`.
|
|
|
|
## JavaScript library
|
|
|
|
This project ships with a tiny bit of JavaScript that listens
|
|
to all click events to:
|
|
|
|
* Support `data-confirm="message"` attributes, which shows
|
|
a confirmation modal with the given message
|
|
|
|
* Support `data-method="patch|post|put|delete"` attributes,
|
|
which sends the current click as a PATCH/POST/PUT/DELETE
|
|
HTTP request. You will need to add `data-to` with the URL
|
|
and `data-csrf` with the CSRF token value
|
|
|
|
* Dispatch a "phoenix.link.click" event. You can listen to this
|
|
event to customize the behaviour above. Returning false from
|
|
this event will disable `data-method`. Stopping propagation
|
|
will disable `data-confirm`
|
|
|
|
To use the functionality above, you must load `priv/static/phoenix_html.js`
|
|
into your build tool.
|
|
|
|
### Overriding the default confirmation behaviour
|
|
|
|
You can override the default implementation by hooking
|
|
into `phoenix.link.click`. Here is an example:
|
|
|
|
```javascript
|
|
window.addEventListener('phoenix.link.click', function (e) {
|
|
// Introduce custom behaviour
|
|
var message = e.target.getAttribute("data-prompt");
|
|
var answer = e.target.getAttribute("data-prompt-answer");
|
|
if(message && answer && (answer != window.prompt(message))) {
|
|
e.preventDefault();
|
|
}
|
|
}, false);
|
|
```
|
|
|
|
"""
|
|
|
|
@doc false
|
|
defmacro __using__(_) do
|
|
raise """
|
|
use Phoenix.HTML is no longer supported in v4.0.
|
|
|
|
To keep compatibility with previous versions, \
|
|
add {:phoenix_html_helpers, "~> 1.0"} to your mix.exs deps
|
|
and then, instead of "use Phoenix.HTML", you might:
|
|
|
|
import Phoenix.HTML
|
|
import Phoenix.HTML.Form
|
|
use PhoenixHTMLHelpers
|
|
|
|
"""
|
|
end
|
|
|
|
@typedoc "Guaranteed to be safe"
|
|
@type safe :: {:safe, iodata}
|
|
|
|
@typedoc "May be safe or unsafe (i.e. it needs to be converted)"
|
|
@type unsafe :: Phoenix.HTML.Safe.t()
|
|
|
|
@doc """
|
|
Marks the given content as raw.
|
|
|
|
This means any HTML code inside the given
|
|
string won't be escaped.
|
|
|
|
iex> raw("<hello>")
|
|
{:safe, "<hello>"}
|
|
iex> raw({:safe, "<hello>"})
|
|
{:safe, "<hello>"}
|
|
iex> raw(nil)
|
|
{:safe, ""}
|
|
|
|
"""
|
|
@spec raw(iodata | safe | nil) :: safe
|
|
def raw({:safe, value}), do: {:safe, value}
|
|
def raw(nil), do: {:safe, ""}
|
|
def raw(value) when is_binary(value) or is_list(value), do: {:safe, value}
|
|
|
|
@doc """
|
|
Escapes the HTML entities in the given term, returning safe iodata.
|
|
|
|
iex> html_escape("<hello>")
|
|
{:safe, [[[] | "<"], "hello" | ">"]}
|
|
|
|
iex> html_escape(~c"<hello>")
|
|
{:safe, ["<", 104, 101, 108, 108, 111, ">"]}
|
|
|
|
iex> html_escape(1)
|
|
{:safe, "1"}
|
|
|
|
iex> html_escape({:safe, "<hello>"})
|
|
{:safe, "<hello>"}
|
|
|
|
"""
|
|
@spec html_escape(unsafe) :: safe
|
|
def html_escape({:safe, _} = safe), do: safe
|
|
def html_escape(other), do: {:safe, Phoenix.HTML.Engine.encode_to_iodata!(other)}
|
|
|
|
@doc """
|
|
Converts a safe result into a string.
|
|
|
|
Fails if the result is not safe. In such cases, you can
|
|
invoke `html_escape/1` or `raw/1` accordingly before.
|
|
|
|
You can combine `html_escape/1` and `safe_to_string/1`
|
|
to convert a data structure to a escaped string:
|
|
|
|
data |> html_escape() |> safe_to_string()
|
|
"""
|
|
@spec safe_to_string(safe) :: String.t()
|
|
def safe_to_string({:safe, iodata}) do
|
|
IO.iodata_to_binary(iodata)
|
|
end
|
|
|
|
@doc ~S"""
|
|
Escapes an enumerable of attributes, returning iodata.
|
|
|
|
The attributes are rendered in the given order. Note if
|
|
a map is given, the key ordering is not guaranteed.
|
|
|
|
The keys and values can be of any shape, as long as they
|
|
implement the `Phoenix.HTML.Safe` protocol. In addition,
|
|
if the key is an atom, it will be "dasherized". In other
|
|
words, `:phx_value_id` will be converted to `phx-value-id`.
|
|
|
|
Furthermore, the following attributes provide behaviour:
|
|
|
|
* `:aria`, `:data`, and `:phx` - they accept a keyword list as
|
|
value. `data: [confirm: "are you sure?"]` is converted to
|
|
`data-confirm="are you sure?"`.
|
|
|
|
* `:class` - it accepts a list of classes as argument. Each
|
|
element in the list is separated by space. `nil` and `false`
|
|
elements are discarded. `class: ["foo", nil, "bar"]` then
|
|
becomes `class="foo bar"`.
|
|
|
|
* `:id` - it is validated raise if a number is given as ID,
|
|
which is not allowed by the HTML spec and leads to unpredictable
|
|
behaviour.
|
|
|
|
## Examples
|
|
|
|
iex> safe_to_string attributes_escape(title: "the title", id: "the id", selected: true)
|
|
" title=\"the title\" id=\"the id\" selected"
|
|
|
|
iex> safe_to_string attributes_escape(%{data: [confirm: "Are you sure?"]})
|
|
" data-confirm=\"Are you sure?\""
|
|
|
|
iex> safe_to_string attributes_escape(%{phx: [value: [foo: "bar"]]})
|
|
" phx-value-foo=\"bar\""
|
|
|
|
"""
|
|
def attributes_escape(attrs) when is_list(attrs) do
|
|
{:safe, build_attrs(attrs)}
|
|
end
|
|
|
|
def attributes_escape(attrs) do
|
|
{:safe, attrs |> Enum.to_list() |> build_attrs()}
|
|
end
|
|
|
|
defp build_attrs([{k, true} | t]),
|
|
do: [?\s, key_escape(k) | build_attrs(t)]
|
|
|
|
defp build_attrs([{_, false} | t]),
|
|
do: build_attrs(t)
|
|
|
|
defp build_attrs([{_, nil} | t]),
|
|
do: build_attrs(t)
|
|
|
|
defp build_attrs([{:id, v} | t]),
|
|
do: [" id=\"", id_value(v), ?" | build_attrs(t)]
|
|
|
|
defp build_attrs([{:class, v} | t]),
|
|
do: [" class=\"", class_value(v), ?" | build_attrs(t)]
|
|
|
|
defp build_attrs([{:aria, v} | t]) when is_list(v),
|
|
do: nested_attrs(v, " aria", t)
|
|
|
|
defp build_attrs([{:data, v} | t]) when is_list(v),
|
|
do: nested_attrs(v, " data", t)
|
|
|
|
defp build_attrs([{:phx, v} | t]) when is_list(v),
|
|
do: nested_attrs(v, " phx", t)
|
|
|
|
defp build_attrs([{"id", v} | t]),
|
|
do: [" id=\"", id_value(v), ?" | build_attrs(t)]
|
|
|
|
defp build_attrs([{"class", v} | t]),
|
|
do: [" class=\"", class_value(v), ?" | build_attrs(t)]
|
|
|
|
defp build_attrs([{"aria", v} | t]) when is_list(v),
|
|
do: nested_attrs(v, " aria", t)
|
|
|
|
defp build_attrs([{"data", v} | t]) when is_list(v),
|
|
do: nested_attrs(v, " data", t)
|
|
|
|
defp build_attrs([{"phx", v} | t]) when is_list(v),
|
|
do: nested_attrs(v, " phx", t)
|
|
|
|
defp build_attrs([{k, v} | t]),
|
|
do: [?\s, key_escape(k), ?=, ?", attr_escape(v), ?" | build_attrs(t)]
|
|
|
|
defp build_attrs([]), do: []
|
|
|
|
defp nested_attrs([{k, true} | kv], attr, t),
|
|
do: [attr, ?-, key_escape(k) | nested_attrs(kv, attr, t)]
|
|
|
|
defp nested_attrs([{_, falsy} | kv], attr, t) when falsy in [false, nil],
|
|
do: nested_attrs(kv, attr, t)
|
|
|
|
defp nested_attrs([{k, v} | kv], attr, t) when is_list(v),
|
|
do: [nested_attrs(v, "#{attr}-#{key_escape(k)}", []) | nested_attrs(kv, attr, t)]
|
|
|
|
defp nested_attrs([{k, v} | kv], attr, t),
|
|
do: [attr, ?-, key_escape(k), ?=, ?", attr_escape(v), ?" | nested_attrs(kv, attr, t)]
|
|
|
|
defp nested_attrs([], _attr, t),
|
|
do: build_attrs(t)
|
|
|
|
defp id_value(value) when is_number(value) do
|
|
raise ArgumentError,
|
|
"attempting to set id attribute to #{value}, " <>
|
|
"but setting the DOM ID to a number can lead to unpredictable behaviour. " <>
|
|
"Instead consider prefixing the id with a string, such as \"user-#{value}\" or similar"
|
|
end
|
|
|
|
defp id_value(value) do
|
|
attr_escape(value)
|
|
end
|
|
|
|
defp class_value(value) when is_list(value) do
|
|
value
|
|
|> list_class_value()
|
|
|> attr_escape()
|
|
end
|
|
|
|
defp class_value(value) do
|
|
attr_escape(value)
|
|
end
|
|
|
|
defp list_class_value(value) do
|
|
value
|
|
|> Enum.flat_map(fn
|
|
nil -> []
|
|
false -> []
|
|
inner when is_list(inner) -> [list_class_value(inner)]
|
|
other -> [other]
|
|
end)
|
|
|> Enum.join(" ")
|
|
end
|
|
|
|
defp key_escape(value) when is_atom(value), do: String.replace(Atom.to_string(value), "_", "-")
|
|
defp key_escape(value), do: attr_escape(value)
|
|
|
|
defp attr_escape({:safe, data}), do: data
|
|
defp attr_escape(nil), do: []
|
|
defp attr_escape(other) when is_binary(other), do: Phoenix.HTML.Engine.html_escape(other)
|
|
defp attr_escape(other), do: Phoenix.HTML.Safe.to_iodata(other)
|
|
|
|
@doc """
|
|
Escapes HTML content to be inserted into a JavaScript string.
|
|
|
|
This function is useful in JavaScript responses when there is a need
|
|
to escape HTML rendered from other templates, like in the following:
|
|
|
|
$("#container").append("<%= javascript_escape(render("post.html", post: @post)) %>");
|
|
|
|
It escapes quotes (double and single), double backslashes and others.
|
|
"""
|
|
@spec javascript_escape(binary) :: binary
|
|
@spec javascript_escape(safe) :: safe
|
|
def javascript_escape({:safe, data}),
|
|
do: {:safe, data |> IO.iodata_to_binary() |> javascript_escape("")}
|
|
|
|
def javascript_escape(data) when is_binary(data),
|
|
do: javascript_escape(data, "")
|
|
|
|
defp javascript_escape(<<0x2028::utf8, t::binary>>, acc),
|
|
do: javascript_escape(t, <<acc::binary, "\\u2028">>)
|
|
|
|
defp javascript_escape(<<0x2029::utf8, t::binary>>, acc),
|
|
do: javascript_escape(t, <<acc::binary, "\\u2029">>)
|
|
|
|
defp javascript_escape(<<0::utf8, t::binary>>, acc),
|
|
do: javascript_escape(t, <<acc::binary, "\\u0000">>)
|
|
|
|
defp javascript_escape(<<"</", t::binary>>, acc),
|
|
do: javascript_escape(t, <<acc::binary, ?<, ?\\, ?/>>)
|
|
|
|
defp javascript_escape(<<"\r\n", t::binary>>, acc),
|
|
do: javascript_escape(t, <<acc::binary, ?\\, ?n>>)
|
|
|
|
defp javascript_escape(<<h, t::binary>>, acc) when h in [?", ?', ?\\, ?`],
|
|
do: javascript_escape(t, <<acc::binary, ?\\, h>>)
|
|
|
|
defp javascript_escape(<<h, t::binary>>, acc) when h in [?\r, ?\n],
|
|
do: javascript_escape(t, <<acc::binary, ?\\, ?n>>)
|
|
|
|
defp javascript_escape(<<h, t::binary>>, acc),
|
|
do: javascript_escape(t, <<acc::binary, h>>)
|
|
|
|
defp javascript_escape(<<>>, acc), do: acc
|
|
|
|
@doc """
|
|
Escapes a string for use as a CSS identifier.
|
|
|
|
## Examples
|
|
|
|
iex> css_escape("hello world")
|
|
"hello\\\\ world"
|
|
|
|
iex> css_escape("-123")
|
|
"-\\\\31 23"
|
|
|
|
"""
|
|
@spec css_escape(String.t()) :: String.t()
|
|
def css_escape(value) when is_binary(value) do
|
|
# This is a direct translation of
|
|
# https://github.com/mathiasbynens/CSS.escape/blob/master/css.escape.js
|
|
# into Elixir.
|
|
value
|
|
|> String.to_charlist()
|
|
|> escape_css_chars()
|
|
|> IO.iodata_to_binary()
|
|
end
|
|
|
|
defp escape_css_chars(chars) do
|
|
case chars do
|
|
# If the character is the first character and is a `-` (U+002D), and
|
|
# there is no second character, […]
|
|
[?- | []] -> ["\\-"]
|
|
_ -> escape_css_chars(chars, 0, [])
|
|
end
|
|
end
|
|
|
|
defp escape_css_chars([], _, acc), do: Enum.reverse(acc)
|
|
|
|
defp escape_css_chars([char | rest], index, acc) do
|
|
escaped =
|
|
cond do
|
|
# If the character is NULL (U+0000), then the REPLACEMENT CHARACTER
|
|
# (U+FFFD).
|
|
char == 0 ->
|
|
<<0xFFFD::utf8>>
|
|
|
|
# If the character is in the range [\1-\1F] (U+0001 to U+001F) or is
|
|
# U+007F,
|
|
# if the character is the first character and is in the range [0-9]
|
|
# (U+0030 to U+0039),
|
|
# if the character is the second character and is in the range [0-9]
|
|
# (U+0030 to U+0039) and the first character is a `-` (U+002D),
|
|
char in 0x0001..0x001F or char == 0x007F or
|
|
(index == 0 and char in ?0..?9) or
|
|
(index == 1 and char in ?0..?9 and hd(acc) == "-") ->
|
|
# https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
|
|
["\\", Integer.to_string(char, 16), " "]
|
|
|
|
# If the character is not handled by one of the above rules and is
|
|
# greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or
|
|
# is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to
|
|
# U+005A), or [a-z] (U+0061 to U+007A), […]
|
|
char >= 0x0080 or char in [?-, ?_] or char in ?0..?9 or char in ?A..?Z or char in ?a..?z ->
|
|
# the character itself
|
|
<<char::utf8>>
|
|
|
|
true ->
|
|
# Otherwise, the escaped character.
|
|
# https://drafts.csswg.org/cssom/#escape-a-character
|
|
["\\", <<char::utf8>>]
|
|
end
|
|
|
|
escape_css_chars(rest, index + 1, [escaped | acc])
|
|
end
|
|
end
|