Skip to content

Commit c1ad602

Browse files
authored
Merge pull request #452 from phoenixframework/sd-css-escape
add Phoenix.HTML.css_escape/1
2 parents 6b67a08 + cdbc402 commit c1ad602

File tree

2 files changed

+148
-1
lines changed

2 files changed

+148
-1
lines changed

lib/phoenix_html.ex

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ defmodule Phoenix.HTML do
119119
iex> html_escape("<hello>")
120120
{:safe, [[[] | "&lt;"], "hello" | "&gt;"]}
121121
122-
iex> html_escape('<hello>')
122+
iex> html_escape(~c"<hello>")
123123
{:safe, ["&lt;", 104, 101, 108, 108, 111, "&gt;"]}
124124
125125
iex> html_escape(1)
@@ -337,4 +337,75 @@ defmodule Phoenix.HTML do
337337
do: javascript_escape(t, <<acc::binary, h>>)
338338

339339
defp javascript_escape(<<>>, acc), do: acc
340+
341+
@doc """
342+
Escapes a string for use as a CSS identifier.
343+
344+
## Examples
345+
346+
iex> css_escape("hello world")
347+
"hello\\\\ world"
348+
349+
iex> css_escape("-123")
350+
"-\\\\31 23"
351+
352+
"""
353+
@spec css_escape(String.t()) :: String.t()
354+
def css_escape(value) when is_binary(value) do
355+
# This is a direct translation of
356+
# https://github.com/mathiasbynens/CSS.escape/blob/master/css.escape.js
357+
# into Elixir.
358+
value
359+
|> String.to_charlist()
360+
|> escape_css_chars()
361+
|> IO.iodata_to_binary()
362+
end
363+
364+
defp escape_css_chars(chars) do
365+
case chars do
366+
# If the character is the first character and is a `-` (U+002D), and
367+
# there is no second character, […]
368+
[?- | []] -> ["\\-"]
369+
_ -> escape_css_chars(chars, 0, [])
370+
end
371+
end
372+
373+
defp escape_css_chars([], _, acc), do: Enum.reverse(acc)
374+
375+
defp escape_css_chars([char | rest], index, acc) do
376+
escaped =
377+
cond do
378+
# If the character is NULL (U+0000), then the REPLACEMENT CHARACTER
379+
# (U+FFFD).
380+
char == 0 ->
381+
<<0xFFFD::utf8>>
382+
383+
# If the character is in the range [\1-\1F] (U+0001 to U+001F) or is
384+
# U+007F,
385+
# if the character is the first character and is in the range [0-9]
386+
# (U+0030 to U+0039),
387+
# if the character is the second character and is in the range [0-9]
388+
# (U+0030 to U+0039) and the first character is a `-` (U+002D),
389+
char in 0x0001..0x001F or char == 0x007F or
390+
(index == 0 and char in ?0..?9) or
391+
(index == 1 and char in ?0..?9 and hd(acc) == "-") ->
392+
# https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
393+
["\\", Integer.to_string(char, 16), " "]
394+
395+
# If the character is not handled by one of the above rules and is
396+
# greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or
397+
# is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to
398+
# U+005A), or [a-z] (U+0061 to U+007A), […]
399+
char >= 0x0080 or char in [?-, ?_] or char in ?0..?9 or char in ?A..?Z or char in ?a..?z ->
400+
# the character itself
401+
<<char::utf8>>
402+
403+
true ->
404+
# Otherwise, the escaped character.
405+
# https://drafts.csswg.org/cssom/#escape-a-character
406+
["\\", <<char::utf8>>]
407+
end
408+
409+
escape_css_chars(rest, index + 1, [escaped | acc])
410+
end
340411
end

test/phoenix_html_test.exs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,80 @@ defmodule Phoenix.HTMLTest do
142142
assert attributes_escape([{"selected", true}]) |> safe_to_string() == ~s( selected)
143143
end
144144
end
145+
146+
describe "css_escape" do
147+
test "null character" do
148+
assert css_escape(<<0>>) == <<0xFFFD::utf8>>
149+
assert css_escape("a\u0000") == "a\ufffd"
150+
assert css_escape("\u0000b") == "\ufffdb"
151+
assert css_escape("a\u0000b") == "a\ufffdb"
152+
end
153+
154+
test "replacement character" do
155+
assert css_escape(<<0xFFFD::utf8>>) == <<0xFFFD::utf8>>
156+
assert css_escape("a\ufffd") == "a\ufffd"
157+
assert css_escape("\ufffdb") == "\ufffdb"
158+
assert css_escape("a\ufffdb") == "a\ufffdb"
159+
end
160+
161+
test "invalid input" do
162+
assert_raise FunctionClauseError, fn -> css_escape(nil) end
163+
end
164+
165+
test "control characters" do
166+
assert css_escape(<<0x01, 0x02, 0x1E, 0x1F>>) == "\\1 \\2 \\1E \\1F "
167+
end
168+
169+
test "leading digit" do
170+
for {digit, expected} <- Enum.zip(0..9, ~w(30 31 32 33 34 35 36 37 38 39)) do
171+
assert css_escape("#{digit}a") == "\\#{expected} a"
172+
end
173+
end
174+
175+
test "non-leading digit" do
176+
for digit <- 0..9 do
177+
assert css_escape("a#{digit}b") == "a#{digit}b"
178+
end
179+
end
180+
181+
test "leading hyphen and digit" do
182+
for {digit, expected} <- Enum.zip(0..9, ~w(30 31 32 33 34 35 36 37 38 39)) do
183+
assert css_escape("-#{digit}a") == "-\\#{expected} a"
184+
end
185+
end
186+
187+
test "hyphens" do
188+
assert css_escape("-") == "\\-"
189+
assert css_escape("-a") == "-a"
190+
assert css_escape("--") == "--"
191+
assert css_escape("--a") == "--a"
192+
end
193+
194+
test "non-ASCII and special characters" do
195+
assert css_escape("🤷🏻‍♂️-_©") == "🤷🏻‍♂️-_©"
196+
197+
assert css_escape(
198+
<<0x7F,
199+
"\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">>
200+
) ==
201+
"\\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"
202+
203+
assert css_escape("\u00a0\u00a1\u00a2") == "\u00a0\u00a1\u00a2"
204+
end
205+
206+
test "alphanumeric characters" do
207+
assert css_escape("a0123456789b") == "a0123456789b"
208+
assert css_escape("abcdefghijklmnopqrstuvwxyz") == "abcdefghijklmnopqrstuvwxyz"
209+
assert css_escape("ABCDEFGHIJKLMNOPQRSTUVWXYZ") == "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
210+
end
211+
212+
test "space and exclamation mark" do
213+
assert css_escape(<<0x20, 0x21, 0x78, 0x79>>) == "\\ \\!xy"
214+
end
215+
216+
test "Unicode characters" do
217+
# astral symbol (U+1D306 TETRAGRAM FOR CENTRE)
218+
assert css_escape(<<0x1D306::utf8>>) == <<0x1D306::utf8>>
219+
end
220+
end
145221
end

0 commit comments

Comments
 (0)