logo

auto_linker

AutoLinker-shim, based on https://git.pleroma.social/pleroma/auto_linker
commit: 4764e1819e67774f063b9d19542d4611de654d09
parent 62652612c9ebe1bf6a8caff8526c6761d102f306
Author: rinpatch <rinpatch@sdf.org>
Date:   Sun, 30 Aug 2020 18:31:48 +0000

Merge branch 'iodata' into 'master'

Support returning result as iodata and as safe iodata

Closes #20

See merge request pleroma/elixir-libraries/linkify!23

Diffstat:

Mlib/linkify.ex9+++++++++
Mlib/linkify/builder.ex45+++++++++++++++++++++++++++++++--------------
Mlib/linkify/parser.ex149+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mtest/linkify_test.exs73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 195 insertions(+), 81 deletions(-)

diff --git a/lib/linkify.ex b/lib/linkify.ex @@ -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 diff --git a/lib/linkify/builder.ex b/lib/linkify/builder.ex @@ -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() + |> format_tag("##{tag}", opts) + end + + 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() + |> format_tag(uri, opts) end - def format_hashtag(attrs, tag, _opts) do - attrs = format_attrs(attrs) - "<a #{attrs}>##{tag}</a>" + def format_tag(attrs, content, %{iodata: true}) do + ["<a ", attrs, ">", content, "</a>"] end - def format_email(attrs, email, _opts) do - attrs = format_attrs(attrs) - ~s(<a #{attrs}>#{email}</a>) + def format_tag(attrs, content, %{iodata: :safe}) do + [{:safe, ["<a ", attrs, ">"]}, content, {:safe, "</a>"}] end - def format_extra(attrs, uri, _opts) do - attrs = format_attrs(attrs) - ~s(<a #{attrs}>#{uri}</a>) + def format_tag(attrs, content, _opts) do + "<a #{attrs}>#{content}</a>" end end diff --git a/lib/linkify/parser.ex b/lib/linkify/parser.ex @@ -62,135 +62,139 @@ 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) + + {buffer, user_acc} = do_parse(input, opts, {"", [], :parsing}) + + if opts[:iodata] do + {buffer, user_acc} + else + {IO.iodata_to_binary(buffer), user_acc} + end end - defp do_parse({"", user_acc}, _opts, {"", acc, _}, _handler), - do: {acc, user_acc} + defp accumulate(acc, buffer), + do: [buffer | acc] + + defp accumulate(acc, buffer, trailing), + do: [trailing, buffer | 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({"", user_acc}, _opts, {"", acc, _}), + do: {Enum.reverse(acc), user_acc} - 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({"@" <> text, user_acc}, opts, {buffer, acc, :skip}), + do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "@"), :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({"<a" <> text, user_acc}, opts, {buffer, acc, :parsing}), + do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "<a"), :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({"<pre" <> text, user_acc}, opts, {buffer, acc, :parsing}), + do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "<pre"), :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({"<code" <> text, user_acc}, opts, {buffer, acc, :parsing}), + do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "<code"), :skip}) - 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({"</a>" <> text, user_acc}, opts, {buffer, acc, :skip}), + do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "</a>"), :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({"</pre>" <> text, user_acc}, opts, {buffer, acc, :skip}), + do: do_parse({text, user_acc}, opts, {"", accumulate(acc, buffer, "</pre>"), :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({"</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, {:html, level}}, type) do - do_parse({text, user_acc}, opts, {"<", acc, {:open, level + 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}}) 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, {"", accumulate(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 +214,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 +276,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 +290,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 +330,21 @@ 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 + Enum.reduce_while(@types, {buffer, user_acc}, fn type, _ -> + if opts[type] == true 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 diff --git a/test/linkify_test.exs b/test/linkify_test.exs @@ -7,6 +7,22 @@ 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 +40,63 @@ 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 +117,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>", []}