Add parser support for iodata and safe iodata

This commit is contained in:
Justin Tormey 2020-07-27 10:40:44 -05:00
parent 62652612c9
commit b1f6c04eef
4 changed files with 168 additions and 80 deletions

View file

@ -40,11 +40,20 @@ defmodule Linkify do
* `hashtag_handler: nil` - a custom handler to validate and formart a hashtag
* `extra: false` - link urls with rarely used schemes (magnet, ipfs, irc, etc.)
* `validate_tld: true` - Set to false to disable TLD validation for urls/emails, also can be set to :no_scheme to validate TLDs only for urls without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't)
* `iodata` - Set to `true` to return iodata as a result, or `:safe` for iodata with linkified anchor tags wrapped in Phoenix.HTML `:safe` tuples (removes need for further sanitization)
"""
def link(text, opts \\ []) do
parse(text, opts)
end
def link_to_iodata(text, opts \\ []) do
parse(text, Keyword.merge(opts, iodata: true))
end
def link_safe(text, opts \\ []) do
parse(text, Keyword.merge(opts, iodata: :safe))
end
def link_map(text, acc, opts \\ []) do
parse({text, acc}, opts)
end

View file

@ -56,8 +56,9 @@ defmodule Linkify.Builder do
|> strip_prefix(Map.get(opts, :strip_prefix, false))
|> truncate(Map.get(opts, :truncate, false))
attrs = format_attrs(attrs)
"<a #{attrs}>#{url}</a>"
attrs
|> format_attrs()
|> format_tag(url, opts)
end
defp format_attrs(attrs) do
@ -123,23 +124,39 @@ defmodule Linkify.Builder do
|> format_extra(uri, opts)
end
def format_mention(attrs, name, _opts) do
attrs = format_attrs(attrs)
"<a #{attrs}>@#{name}</a>"
def format_mention(attrs, name, opts) do
attrs
|> format_attrs()
|> format_tag("@#{name}", opts)
end
def format_hashtag(attrs, tag, _opts) do
attrs = format_attrs(attrs)
"<a #{attrs}>##{tag}</a>"
def format_hashtag(attrs, tag, opts) do
attrs
|> format_attrs()
|> format_tag("##{tag}", opts)
end
def format_email(attrs, email, _opts) do
attrs = format_attrs(attrs)
~s(<a #{attrs}>#{email}</a>)
def format_email(attrs, email, opts) do
attrs
|> format_attrs()
|> format_tag(email, opts)
end
def format_extra(attrs, uri, _opts) do
attrs = format_attrs(attrs)
~s(<a #{attrs}>#{uri}</a>)
def format_extra(attrs, uri, opts) do
attrs
|> format_attrs()
|> format_tag(uri, opts)
end
def format_tag(attrs, content, %{iodata: true}) do
["<a ", attrs, ">", content, "</a>"]
end
def format_tag(attrs, content, %{iodata: :safe}) do
[{:safe, ["<a ", attrs, ">"]}, content, {:safe, "</a>"}]
end
def format_tag(attrs, content, _opts) do
"<a #{attrs}>#{content}</a>"
end
end

View file

@ -62,135 +62,142 @@ defmodule Linkify.Parser do
def parse(input, opts) do
opts = Map.merge(@default_opts, opts)
opts_list = Map.to_list(opts)
Enum.reduce(@types, input, fn
type, input ->
if {type, true} in opts_list do
do_parse(input, opts, {"", "", :parsing}, type)
else
input
end
end)
acc = if opts[:iodata], do: [], else: ""
do_parse(input, opts, {"", acc, :parsing})
end
defp do_parse({"", user_acc}, _opts, {"", acc, _}, _handler),
defp accumulate(acc, buffer) when is_list(acc),
do: [buffer | acc]
defp accumulate(acc, buffer) when is_binary(acc),
do: acc <> buffer
defp accumulate(acc, buffer, trailing) when is_list(acc),
do: [trailing, buffer | acc]
defp accumulate(acc, buffer, trailing) when is_binary(acc),
do: acc <> buffer <> trailing
defp do_parse({"", user_acc}, _opts, {"", acc, _}) when is_list(acc),
do: {Enum.reverse(acc), user_acc}
defp do_parse({"", user_acc}, _opts, {"", acc, _}) when is_binary(acc),
do: {acc, user_acc}
defp do_parse({"@" <> text, user_acc}, opts, {buffer, acc, :skip}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> "@", :skip}, type)
defp do_parse({"@" <> text, user_acc}, opts, {buffer, acc, :skip}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "@"), :skip})
defp do_parse({"<a" <> text, user_acc}, opts, {buffer, acc, :parsing}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> "<a", :skip}, type)
defp do_parse({"<a" <> text, user_acc}, opts, {buffer, acc, :parsing}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "<a"), :skip})
defp do_parse({"<pre" <> text, user_acc}, opts, {buffer, acc, :parsing}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> "<pre", :skip}, type)
defp do_parse({"<pre" <> text, user_acc}, opts, {buffer, acc, :parsing}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "<pre"), :skip})
defp do_parse({"<code" <> text, user_acc}, opts, {buffer, acc, :parsing}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> "<code", :skip}, type)
defp do_parse({"<code" <> text, user_acc}, opts, {buffer, acc, :parsing}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "<code"), :skip})
defp do_parse({"</a>" <> text, user_acc}, opts, {buffer, acc, :skip}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> "</a>", :parsing}, type)
defp do_parse({"</a>" <> text, user_acc}, opts, {buffer, acc, :skip}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "</a>"), :parsing})
defp do_parse({"</pre>" <> text, user_acc}, opts, {buffer, acc, :skip}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> "</pre>", :parsing}, type)
defp do_parse({"</pre>" <> text, user_acc}, opts, {buffer, acc, :skip}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "</pre>"), :parsing})
defp do_parse({"</code>" <> text, user_acc}, opts, {buffer, acc, :skip}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> "</code>", :parsing}, type)
defp do_parse({"</code>" <> text, user_acc}, opts, {buffer, acc, :skip}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "</code>"), :parsing})
defp do_parse({"<" <> text, user_acc}, opts, {"", acc, :parsing}, type),
do: do_parse({text, user_acc}, opts, {"<", acc, {:open, 1}}, type)
defp do_parse({"<" <> text, user_acc}, opts, {"", acc, :parsing}),
do: do_parse({text, user_acc}, opts, {"<", acc, {:open, 1}})
defp do_parse({"<" <> text, user_acc}, opts, {"", acc, {:html, level}}, type) do
do_parse({text, user_acc}, opts, {"<", acc, {:open, level + 1}}, type)
defp do_parse({"<" <> text, user_acc}, opts, {"", acc, {:html, level}}) do
do_parse({text, user_acc}, opts, {"<", acc, {:open, level + 1}})
end
defp do_parse({">" <> text, user_acc}, opts, {buffer, acc, {:attrs, level}}, type),
defp do_parse({">" <> text, user_acc}, opts, {buffer, acc, {:attrs, level}}),
do:
do_parse(
{text, user_acc},
opts,
{"", acc <> buffer <> ">", {:html, level}},
type
{"", accumulate(acc, buffer, ">"), {:html, level}}
)
defp do_parse({<<ch::8>> <> text, user_acc}, opts, {"", acc, {:attrs, level}}, type) do
do_parse({text, user_acc}, opts, {"", acc <> <<ch::8>>, {:attrs, level}}, type)
defp do_parse({<<ch::8>> <> text, user_acc}, opts, {"", acc, {:attrs, level}}) do
do_parse({text, user_acc}, opts, {"", accumulate(acc, <<ch::8>>), {:attrs, level}})
end
defp do_parse({"</" <> text, user_acc}, opts, {buffer, acc, {:html, level}}, type) do
{buffer, user_acc} = link(type, buffer, opts, user_acc)
defp do_parse({"</" <> text, user_acc}, opts, {buffer, acc, {:html, level}}) do
{buffer, user_acc} = link(buffer, opts, user_acc)
do_parse(
{text, user_acc},
opts,
{"", acc <> buffer <> "</", {:close, level}},
type
{"", accumulate(acc, buffer, "</"), {:close, level}}
)
end
defp do_parse({">" <> text, user_acc}, opts, {buffer, acc, {:close, 1}}, type),
do: do_parse({text, user_acc}, opts, {"", acc <> buffer <> ">", :parsing}, type)
defp do_parse({">" <> text, user_acc}, opts, {buffer, acc, {:close, 1}}),
do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, ">"), :parsing})
defp do_parse({">" <> text, user_acc}, opts, {buffer, acc, {:close, level}}, type),
defp do_parse({">" <> text, user_acc}, opts, {buffer, acc, {:close, level}}),
do:
do_parse(
{text, user_acc},
opts,
{"", acc <> buffer <> ">", {:html, level - 1}},
type
{"", accumulate(acc, buffer, ">"), {:html, level - 1}}
)
defp do_parse({text, user_acc}, opts, {buffer, acc, {:open, level}}, type) do
do_parse({text, user_acc}, opts, {"", acc <> buffer, {:attrs, level}}, type)
defp do_parse({text, user_acc}, opts, {buffer, acc, {:open, level}}) do
do_parse({text, user_acc}, opts, {"", acc <> buffer, {:attrs, level}})
end
defp do_parse(
{<<char::bytes-size(1), text::binary>>, user_acc},
opts,
{buffer, acc, state},
type
{buffer, acc, state}
)
when char in [" ", "\r", "\n"] do
{buffer, user_acc} = link(type, buffer, opts, user_acc)
{buffer, user_acc} = link(buffer, opts, user_acc)
do_parse(
{text, user_acc},
opts,
{"", acc <> buffer <> char, state},
type
{"", accumulate(acc, buffer, char), state}
)
end
defp do_parse({<<ch::8>>, user_acc}, opts, {buffer, acc, state}, type) do
{buffer, user_acc} = link(type, buffer <> <<ch::8>>, opts, user_acc)
defp do_parse({<<ch::8>>, user_acc}, opts, {buffer, acc, state}) do
{buffer, user_acc} = link(buffer <> <<ch::8>>, opts, user_acc)
do_parse(
{"", user_acc},
opts,
{"", acc <> buffer, state},
type
{"", accumulate(acc, buffer), state}
)
end
defp do_parse({<<ch::8>> <> text, user_acc}, opts, {buffer, acc, state}, type),
do: do_parse({text, user_acc}, opts, {buffer <> <<ch::8>>, acc, state}, type)
defp do_parse({<<ch::8>> <> text, user_acc}, opts, {buffer, acc, state}),
do: do_parse({text, user_acc}, opts, {buffer <> <<ch::8>>, acc, state})
def check_and_link(:url, buffer, opts, _user_acc) do
str = strip_parens(buffer)
if url?(str, opts) do
case @match_url |> Regex.run(str, capture: [:url]) |> hd() do
^buffer -> link_url(buffer, opts)
url -> String.replace(buffer, url, link_url(url, opts))
^buffer ->
link_url(buffer, opts)
url ->
buffer
|> String.split(url)
|> Enum.intersperse(link_url(url, opts))
|> (if opts[:iodata], do: & &1, else: & Enum.join(&1)).()
end
else
buffer
:nomatch
end
end
def check_and_link(:email, buffer, opts, _user_acc) do
if email?(buffer, opts), do: link_email(buffer, opts), else: buffer
if email?(buffer, opts), do: link_email(buffer, opts), else: :nomatch
end
def check_and_link(:mention, buffer, opts, user_acc) do
@ -210,7 +217,7 @@ defmodule Linkify.Parser do
end
def check_and_link(:extra, buffer, opts, _user_acc) do
if String.starts_with?(buffer, @prefix_extra), do: link_extra(buffer, opts), else: buffer
if String.starts_with?(buffer, @prefix_extra), do: link_extra(buffer, opts), else: :nomatch
end
defp strip_parens("(" <> buffer) do
@ -272,7 +279,7 @@ defmodule Linkify.Parser do
end
end
def link_hashtag(nil, buffer, _, _user_acc), do: buffer
def link_hashtag(nil, _buffer, _, _user_acc), do: :nomatch
def link_hashtag(hashtag, buffer, %{hashtag_handler: hashtag_handler} = opts, user_acc) do
hashtag
@ -286,7 +293,7 @@ defmodule Linkify.Parser do
|> maybe_update_buffer(hashtag, buffer)
end
def link_mention(nil, buffer, _, user_acc), do: {buffer, user_acc}
def link_mention(nil, _buffer, _, _user_acc), do: :nomatch
def link_mention(mention, buffer, %{mention_handler: mention_handler} = opts, user_acc) do
mention
@ -326,10 +333,23 @@ defmodule Linkify.Parser do
Builder.create_extra_link(buffer, opts)
end
defp link(type, buffer, opts, user_acc) do
defp link(buffer, opts, user_acc) do
opts_list = Map.to_list(opts)
Enum.reduce_while @types, {buffer, user_acc}, fn type, _ ->
if {type, true} in opts_list do
check_and_link_reducer(type, buffer, opts, user_acc)
else
{:cont, {buffer, user_acc}}
end
end
end
defp check_and_link_reducer(type, buffer, opts, user_acc) do
case check_and_link(type, buffer, opts, user_acc) do
{buffer, user_acc} -> {buffer, user_acc}
buffer -> {buffer, user_acc}
:nomatch -> {:cont, {buffer, user_acc}}
{buffer, user_acc} -> {:halt, {buffer, user_acc}}
buffer -> {:halt, {buffer, user_acc}}
end
end
end

View file

@ -7,6 +7,16 @@ defmodule LinkifyTest do
"<a href=\"http://google.com\">google.com</a>"
end
test "default link iodata" do
assert Linkify.link_to_iodata("google.com") ==
[["<a ", "href=\"http://google.com\"", ">", "google.com", "</a>"]]
end
test "default link safe iodata" do
assert Linkify.link_safe("google.com") ==
[[{:safe, ["<a ", "href=\"http://google.com\"", ">"]}, "google.com", {:safe, "</a>"}]]
end
test "does on link existing links" do
text = ~s(<a href="http://google.com">google.com</a>)
assert Linkify.link(text) == text
@ -24,16 +34,38 @@ defmodule LinkifyTest do
) == expected
end
test "all kinds of links iodata" do
text = "hello google.com https://ddg.com user@email.com irc:///mIRC"
expected =
["hello", " ", ["<a ", "href=\"http://google.com\"", ">", "google.com", "</a>"], " ", ["<a ", "href=\"https://ddg.com\"", ">", "https://ddg.com", "</a>"], " ", ["<a ", "href=\"mailto:user@email.com\"", ">", "user@email.com", "</a>"], " ", ["<a ", "href=\"irc:///mIRC\"", ">", "irc:///mIRC", "</a>"]]
assert Linkify.link_to_iodata(text,
email: true,
extra: true
) == expected
end
test "class attribute" do
assert Linkify.link("google.com", class: "linkified") ==
"<a href=\"http://google.com\" class=\"linkified\">google.com</a>"
end
test "class attribute iodata" do
assert Linkify.link_to_iodata("google.com", class: "linkified") ==
[["<a ", "href=\"http://google.com\" class=\"linkified\"", ">", "google.com", "</a>"]]
end
test "rel attribute" do
assert Linkify.link("google.com", rel: "noopener noreferrer") ==
"<a href=\"http://google.com\" rel=\"noopener noreferrer\">google.com</a>"
end
test "rel attribute iodata" do
assert Linkify.link_to_iodata("google.com", rel: "noopener noreferrer") ==
[["<a ", "href=\"http://google.com\" rel=\"noopener noreferrer\"", ">", "google.com", "</a>"]]
end
test "rel as function" do
text = "google.com"
@ -54,6 +86,16 @@ defmodule LinkifyTest do
assert Linkify.link(text, rel: custom_rel) == expected
end
test "strip parens" do
assert Linkify.link("(google.com)") ==
"(<a href=\"http://google.com\">google.com</a>)"
end
test "strip parens iodata" do
assert Linkify.link_to_iodata("(google.com)") ==
[["(", ["<a ", "href=\"http://google.com\"", ">", "google.com", "</a>"], ")"]]
end
test "link_map/2" do
assert Linkify.link_map("google.com", []) ==
{"<a href=\"http://google.com\">google.com</a>", []}