Skip to content

Commit

Permalink
add Phoenix.HTML.css_escape/1
Browse files Browse the repository at this point in the history
  • Loading branch information
SteffenDE committed Oct 21, 2024
1 parent c966418 commit 3b742c9
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 1 deletion.
17 changes: 16 additions & 1 deletion lib/phoenix_html.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ defmodule Phoenix.HTML do
iex> html_escape("<hello>")
{:safe, [[[] | "&lt;"], "hello" | "&gt;"]}
iex> html_escape('<hello>')
iex> html_escape(~c"<hello>")
{:safe, ["&lt;", 104, 101, 108, 108, 111, "&gt;"]}
iex> html_escape(1)
Expand All @@ -133,6 +133,21 @@ defmodule Phoenix.HTML do
def html_escape({:safe, _} = safe), do: safe
def html_escape(other), do: {:safe, Phoenix.HTML.Engine.encode_to_iodata!(other)}

@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), do: Phoenix.HTML.CSS.escape(value)

@doc """
Converts a safe result into a string.
Expand Down
77 changes: 77 additions & 0 deletions lib/phoenix_html/css.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
defmodule Phoenix.HTML.CSS do
@moduledoc false

# This is a direct translation of
# https://github.com/mathiasbynens/CSS.escape/blob/master/css.escape.js
# into Elixir.

@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 escape(String.t()) :: String.t()
def escape(value) when is_binary(value) do
value
|> String.to_charlist()
|> escape_chars()
|> IO.iodata_to_binary()
end

def escape(_), do: raise(ArgumentError, "CSS.escape requires a string argument")

defp escape_chars(chars) do
case chars do
# If the character is the first character and is a `-` (U+002D), and
# there is no second character, […]
[?- | []] -> ["\\-"]
_ -> do_escape_chars(chars, 0, [])
end
end

defp do_escape_chars([], _, acc), do: Enum.reverse(acc)

defp do_escape_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

do_escape_chars(rest, index + 1, [escaped | acc])
end
end
79 changes: 79 additions & 0 deletions test/phoenix_html/css_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
defmodule Phoenix.HTML.CSSTest do
use ExUnit.Case, async: true

alias Phoenix.HTML.CSS

test "null character" do
assert CSS.escape(<<0>>) == <<0xFFFD::utf8>>
assert CSS.escape("a\u0000") == "a\ufffd"
assert CSS.escape("\u0000b") == "\ufffdb"
assert CSS.escape("a\u0000b") == "a\ufffdb"
end

test "replacement character" do
assert CSS.escape(<<0xFFFD::utf8>>) == <<0xFFFD::utf8>>
assert CSS.escape("a\ufffd") == "a\ufffd"
assert CSS.escape("\ufffdb") == "\ufffdb"
assert CSS.escape("a\ufffdb") == "a\ufffdb"
end

test "invalid input" do
assert_raise ArgumentError, fn -> CSS.escape(nil) end
end

test "control characters" do
assert CSS.escape(<<0x01, 0x02, 0x1E, 0x1F>>) == "\\1 \\2 \\1E \\1F "
end

test "leading digit" do
for {digit, expected} <- Enum.zip(0..9, ~w(30 31 32 33 34 35 36 37 38 39)) do
assert CSS.escape("#{digit}a") == "\\#{expected} a"
end
end

test "non-leading digit" do
for digit <- 0..9 do
assert CSS.escape("a#{digit}b") == "a#{digit}b"
end
end

test "leading hyphen and digit" do
for {digit, expected} <- Enum.zip(0..9, ~w(30 31 32 33 34 35 36 37 38 39)) do
assert CSS.escape("-#{digit}a") == "-\\#{expected} a"
end
end

test "hyphens" do
assert CSS.escape("-") == "\\-"
assert CSS.escape("-a") == "-a"
assert CSS.escape("--") == "--"
assert CSS.escape("--a") == "--a"
end

test "non-ASCII and special characters" do
assert CSS.escape("🤷🏻‍♂️-_©") == "🤷🏻‍♂️-_©"

assert CSS.escape(
<<0x7F,
"\u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008a\u008b\u008c\u008d\u008e\u008f\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009a\u009b\u009c\u009d\u009e\u009f">>
) ==
"\\7F \u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008a\u008b\u008c\u008d\u008e\u008f\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009a\u009b\u009c\u009d\u009e\u009f"

assert CSS.escape("\u00a0\u00a1\u00a2") == "\u00a0\u00a1\u00a2"
end

test "alphanumeric characters" do
assert CSS.escape("a0123456789b") == "a0123456789b"
assert CSS.escape("abcdefghijklmnopqrstuvwxyz") == "abcdefghijklmnopqrstuvwxyz"
assert CSS.escape("ABCDEFGHIJKLMNOPQRSTUVWXYZ") == "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
end

test "space and exclamation mark" do
assert CSS.escape(<<0x20, 0x21, 0x78, 0x79>>) == "\\ \\!xy"
end

test "Unicode characters" do
# astral symbol (U+1D306 TETRAGRAM FOR CENTRE)
assert CSS.escape(<<0x1D306::utf8>>) == <<0x1D306::utf8>>
end
end

0 comments on commit 3b742c9

Please sign in to comment.