defmodule ExAutolink do @moduledoc """ This simple module is used for converting http(s):// links in text to HTML hyperlinks. It doesn't depend on Phoenix.HTML, but can be used in conjuction with Phoenix. """ @doc ~S""" This method is used for parsing strings or text blocks. We convert all links starting with http:// or https:// to HTML links. ## Examples iex> ExAutolink.link("https://elixir-lang.org") "https://elixir-lang.org" iex> ExAutolink.link("Go here: https://elixir-lang.org/.") "Go here: https://elixir-lang.org/." iex> ExAutolink.link("https://ja.wikipedia.org/wiki/Elixir_(プログラミング言語)") "https://ja.wikipedia.org/wiki/Elixir_(プログラミング言語)" iex> ExAutolink.link("https://elixir-lang.org!", args: %{class: "myclass active", rel: "nofollow noreferrer"}) "https://elixir-lang.org!" """ def link(text, options \\ []) def link("", _options), do: "" def link(text, []), do: build_link(text) def link(text, args: args), do: build_link(text, build_arguments(args)) defp build_link(text, extra \\ <<>>) do Regex.replace(~r{(https?://[^\s]+)}, text, fn url -> {:ok, url_part, punctuation} = url |> reverse() |> parse_punctuation() ~s(#{url_part}#{punctuation}) end) end # parse_punctuation/2 is used to handle punctuation by recursively scanning # the string in reversed order. It is used to split punctuation from the # actual url (trailing full stop, question marks, etc. i.e. anything not # part of the url itself). defp parse_punctuation(reversed, punctuation \\ <<>>) # This matches cases when punctuation contains any kind of closing bracket. defp parse_punctuation(<>, punctuation), do: parse_brackets(reversed, punctuation, ?(, ?)) defp parse_punctuation(<>, punctuation), do: parse_brackets(reversed, punctuation, ?[, ?]) defp parse_punctuation(<>, punctuation), do: parse_brackets(reversed, punctuation, ?{, ?}) defp parse_punctuation(<>, punctuation) do if <> =~ ~r/^[^\p{L}\p{N}\/-=&]$/ do # The regex is used to match on punctuation characters. # # Anything that is NOT a letter, number, forward slash, dash, equal sign, # or ampersand, is matched. We thus assume it is punctuation. parse_punctuation(reversed, punctuation <> <>) else {:ok, reverse(<> <> reversed), reverse(punctuation)} end end defp parse_brackets(reversed, punctuation, opening, closing) do # We use find_opening/2 to search if there is a matching opening bracket # earlier in the string. case find_opening(reversed, opening, closing) do {:found} -> {:ok, reverse(<> <> reversed), reverse(punctuation)} {:not_found} -> parse_punctuation(reversed, punctuation <> <>) end end defp find_opening(<<>>, _opening, _closing), do: {:not_found} defp find_opening(<>, opening, closing) do # Recursively look for the first encountered opening bracket, without # stumbling upon another closing bracket. case last_char do ^opening -> {:found} ^closing -> {:not_found} _ -> find_opening(reversed, opening, closing) end end defp build_arguments(args) when is_map(args) do for {k, v} <- args, into: "", do: ~s( #{k}="#{v}") end defp reverse(binary, result \\ <<>>) defp reverse(<<>>, result), do: result defp reverse(<>, result), do: reverse(rest, <> <> result) end