add phone number linking

This commit is contained in:
Stephen M. Pallen 2017-11-17 13:36:37 -05:00
parent b30df6cb37
commit 80b9b85040
9 changed files with 242 additions and 53 deletions

View file

@ -7,7 +7,7 @@
[license-img]: http://img.shields.io/badge/license-MIT-brightgreen.svg
[license]: http://opensource.org/licenses/MIT
AutoLinker is a basic package for turning website names into links.
AutoLinker is a basic package for turning website names, and phone numbers into links.
Use this package in your web view to convert web references into click-able links.
@ -19,13 +19,14 @@ The package can be installed by adding `auto_linker` to your list of dependencie
```elixir
def deps do
[{:auto_linker, "~> 0.1"}]
[{:auto_linker, "~> 0.2"}]
end
```
## Usage
```
The following examples illustrate some examples on how to use the auto linker.
iex> AutoLinker.link("google.com")
"<a href='http://google.com' class='auto-linker' target='_blank' rel='noopener noreferrer'>google.com</a>"
@ -34,10 +35,28 @@ iex> AutoLinker.link("google.com", new_window: false, rel: false)
iex> AutoLinker.link("google.com", new_window: false, rel: false, class: false)
"<a href='http://google.com'>google.com</a>"
```
iex> AutoLinker.link("call me at x9999")
~s{call me at <a href="" class="phone-number" data-number="9999">x9999</a>}
iex> AutoLinker.link("or at home on 555.555.5555")
~s{or at home on <a href="" class="phone-number" data-number="55555555555">555.555.5555</a>}
iex> AutoLinker.link(", work (555) 555-5555")
~s{, work <a href="" class="phone-number" data-number="5555555555">(555) 555-5555</a>}
See the [Docs](https://hexdocs.pm/auto_linker/) for more examples
## Configuration
By default, link parsing is enabled and phone parsing is disabled.
```elixir
# enable phone parsing, and disable link parsing
config :auto_linker, opts: [phone: true, url: false]
```
## License
`auto_linker` is Copyright (c) 2017 E-MetroTel

View file

@ -44,13 +44,7 @@ defmodule AutoLinker do
Note that passing opts to `link/2` will override the configuration settings.
"""
def link(text, opts \\ []) do
opts =
:auto_linker
|> Application.get_all_env()
|> Keyword.merge(opts)
parse text, Enum.into(opts, %{})
parse text, opts
end
end

View file

@ -56,4 +56,36 @@ defmodule AutoLinker.Builder do
|> String.replace(~r/^www\./, "")
end
defp strip_prefix(url, _), do: url
def create_phone_link([], buffer, _) do
buffer
end
def create_phone_link([h | t], buffer, opts) do
create_phone_link t, format_phone_link(h, buffer, opts), opts
end
def format_phone_link([h | _], buffer, opts) do
val =
h
|> String.replace(~r/[\.\+\- x\(\)]+/, "")
|> format_phone_link(h, opts)
# val = ~s'<a href="#" class="phone-number" data-phone="#{number}">#{h}</a>'
String.replace(buffer, h, val)
end
def format_phone_link(number, original, opts) do
tag = opts[:tag] || "a"
class = opts[:class] || "phone-number"
data_phone = opts[:data_phone] || "data-phone"
attrs = format_attributes(opts[:attributes] || [])
href = opts[:href] || "#"
~s'<#{tag} href="#{href}" class="#{class}" #{data_phone}="#{number}"#{attrs}>#{original}</#{tag}>'
end
defp format_attributes(attrs) do
Enum.reduce(attrs, "", fn {name, value}, acc ->
acc <> ~s' #{name}="#{value}"'
end)
end
end

View file

@ -6,98 +6,177 @@ defmodule AutoLinker.Parser do
alias AutoLinker.Builder
@doc """
Parse the given string.
Parse the given string, identifying items to link.
Parses the string, replacing the matching urls with an html link.
Parses the string, replacing the matching urls and phone numbers with an html link.
## Examples
iex> AutoLinker.Parser.parse("Check out google.com")
"Check out <a href='http://google.com' class='auto-linker' target='_blank' rel='noopener noreferrer'>google.com</a>"
iex> AutoLinker.Parser.parse("call me at x9999", phone: true)
~s{call me at <a href="#" class="phone-number" data-phone="9999">x9999</a>}
iex> AutoLinker.Parser.parse("or at home on 555.555.5555", phone: true)
~s{or at home on <a href="#" class="phone-number" data-phone="5555555555">555.555.5555</a>}
iex> AutoLinker.Parser.parse(", work (555) 555-5555", phone: true)
~s{, work <a href="#" class="phone-number" data-phone="5555555555">(555) 555-5555</a>}
"""
@match_dots ~r/\.\.+/
# @invalid_url ~r/\.\.+/
@invalid_url ~r/(\.\.+)|(^(\d+\.){1,2}\d+$)/
@match_url ~r{^[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$}
@match_url ~r{^[\w\.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$}
@match_scheme ~r{^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$}
@match_phone ~r"((?:\+|00)[17](?:[ \-\.])?|(?:\+|00)[1-9]\d{0,2}(?:[ \-\.])?|(?:\+|00)1[\-\.]\d{3}(?:[ \-\.])?)?(0\d|\([0-9]{3}\)|[1-9]{0,3})(?:((?:[ \-\.])[0-9]{2}){4}|((?:[0-9]{2}){4})|((?:[ \-\.])[0-9]{3}(?:[ \-\.])[0-9]{4})|([0-9]{7}))|(x[0-9][0-9]+)"
@default_opts ~w(url)a
def parse(text, opts \\ %{})
def parse(text, list) when is_list(list), do: parse(text, Enum.into(list, %{}))
def parse(text, opts) do
config =
:auto_linker
|> Application.get_env(:opts, [])
|> Enum.into(%{})
config =
:auto_linker
|> Application.get_env(:attributes, [])
|> Enum.into(config)
opts =
Enum.reduce @default_opts, opts, fn opt, acc ->
if is_nil(opts[opt]) and is_nil(config[opt]) do
Map.put acc, opt, true
else
acc
end
end
do_parse text, Map.merge(config, opts)
end
defp do_parse(text, %{phone: false} = opts), do: do_parse(text, Map.delete(opts, :phone))
defp do_parse(text, %{url: false} = opts), do: do_parse(text, Map.delete(opts, :url))
defp do_parse(text, %{phone: _} = opts) do
text
|> do_parse(false, opts, {"", "", :parsing}, &check_and_link_phone/3)
|> do_parse(Map.delete(opts, :phone))
end
defp do_parse(text, %{url: _} = opts) do
if (exclude = Map.get(opts, :exclude_pattern, false)) && String.starts_with?(text, exclude) do
text
else
parse(text, Map.get(opts, :scheme, false), opts, {"", "", :parsing})
do_parse(text, Map.get(opts, :scheme, false), opts, {"", "", :parsing}, &check_and_link/3)
end
|> do_parse(Map.delete(opts, :url))
end
defp do_parse(text, _), do: text
# state = {buffer, acc, state}
defp parse("", _scheme, _opts ,{"", acc, _}),
defp do_parse("", _scheme, _opts ,{"", acc, _}, _handler),
do: acc
defp parse("", scheme, opts ,{buffer, acc, _}),
do: acc <> check_and_link(buffer, scheme, opts)
defp parse("<" <> text, scheme, opts, {"", acc, :parsing}),
do: parse(text, scheme, opts, {"<", acc, {:open, 1}})
defp do_parse("", scheme, opts ,{buffer, acc, _}, handler),
do: acc <> handler.(buffer, scheme, opts)
defp parse(">" <> text, scheme, opts, {buffer, acc, {:attrs, level}}),
do: parse(text, scheme, opts, {"", acc <> buffer <> ">", {:html, level}})
defp do_parse("<" <> text, scheme, opts, {"", acc, :parsing}, handler),
do: do_parse(text, scheme, opts, {"<", acc, {:open, 1}}, handler)
defp parse(<<ch::8>> <> text, scheme, opts, {"", acc, {:attrs, level}}),
do: parse(text, scheme, opts, {"", acc <> <<ch::8>>, {:attrs, level}})
defp do_parse(">" <> text, scheme, opts, {buffer, acc, {:attrs, level}}, handler),
do: do_parse(text, scheme, opts, {"", acc <> buffer <> ">", {:html, level}}, handler)
defp parse("</" <> text, scheme, opts, {buffer, acc, {:html, level}}),
do: parse(text, scheme, opts,
{"", acc <> check_and_link(buffer, scheme, opts) <> "</", {:close, level}})
defp do_parse(<<ch::8>> <> text, scheme, opts, {"", acc, {:attrs, level}}, handler),
do: do_parse(text, scheme, opts, {"", acc <> <<ch::8>>, {:attrs, level}}, handler)
defp parse(">" <> text, scheme, opts, {buffer, acc, {:close, 1}}),
do: parse(text, scheme, opts, {"", acc <> buffer <> ">", :parsing})
defp do_parse("</" <> text, scheme, opts, {buffer, acc, {:html, level}}, handler),
do: do_parse(text, scheme, opts,
{"", acc <> handler.(buffer, scheme, opts) <> "</", {:close, level}}, handler)
defp parse(">" <> text, scheme, opts, {buffer, acc, {:close, level}}),
do: parse(text, scheme, opts, {"", acc <> buffer <> ">", {:html, level - 1}})
defp do_parse(">" <> text, scheme, opts, {buffer, acc, {:close, 1}}, handler),
do: do_parse(text, scheme, opts, {"", acc <> buffer <> ">", :parsing}, handler)
defp parse(" " <> text, scheme, opts, {buffer, acc, {:open, level}}),
do: parse(text, scheme, opts, {"", acc <> buffer <> " ", {:attrs, level}})
defp parse("\n" <> text, scheme, opts, {buffer, acc, {:open, level}}),
do: parse(text, scheme, opts, {"", acc <> buffer <> "\n", {:attrs, level}})
defp do_parse(">" <> text, scheme, opts, {buffer, acc, {:close, level}}, handler),
do: do_parse(text, scheme, opts, {"", acc <> buffer <> ">", {:html, level - 1}}, handler)
defp do_parse(" " <> text, scheme, opts, {buffer, acc, {:open, level}}, handler),
do: do_parse(text, scheme, opts, {"", acc <> buffer <> " ", {:attrs, level}}, handler)
defp do_parse("\n" <> text, scheme, opts, {buffer, acc, {:open, level}}, handler),
do: do_parse(text, scheme, opts, {"", acc <> buffer <> "\n", {:attrs, level}}, handler)
# default cases where state is not important
defp parse(" " <> text, scheme, opts, {buffer, acc, state}),
do: parse(text, scheme, opts,
{"", acc <> check_and_link(buffer, scheme, opts) <> " ", state})
defp parse("\n" <> text, scheme, opts, {buffer, acc, state}),
do: parse(text, scheme, opts,
{"", acc <> check_and_link(buffer, scheme, opts) <> "\n", state})
defp do_parse(" " <> text, scheme, %{phone: _} = opts, {buffer, acc, state}, handler),
do: do_parse(text, scheme, opts, {buffer <> " ", acc, state}, handler)
defp parse(<<ch::8>> <> text, scheme, opts, {buffer, acc, state}),
do: parse(text, scheme, opts, {buffer <> <<ch::8>>, acc, state})
defp do_parse(" " <> text, scheme, opts, {buffer, acc, state}, handler),
do: do_parse(text, scheme, opts,
{"", acc <> handler.(buffer, scheme, opts) <> " ", state}, handler)
defp do_parse("\n" <> text, scheme, opts, {buffer, acc, state}, handler),
do: do_parse(text, scheme, opts,
{"", acc <> handler.(buffer, scheme, opts) <> "\n", state}, handler)
defp do_parse(<<ch::8>>, scheme, opts, {buffer, acc, state}, handler),
do: do_parse("", scheme, opts,
{"", acc <> handler.(buffer <> <<ch::8>>, scheme, opts), state}, handler)
defp do_parse(<<ch::8>> <> text, scheme, opts, {buffer, acc, state}, handler),
do: do_parse(text, scheme, opts, {buffer <> <<ch::8>>, acc, state}, handler)
defp check_and_link(buffer, scheme, opts) do
def check_and_link(buffer, scheme, opts) do
buffer
|> is_url?(scheme)
|> link_url(buffer, opts)
end
def check_and_link_phone(buffer, _, opts) do
buffer
|> match_phone
|> link_phone(buffer, opts)
end
@doc false
def is_url?(buffer, true) do
if Regex.match? @match_dots, buffer do
if Regex.match? @invalid_url, buffer do
false
else
Regex.match? @match_scheme, buffer
end
end
def is_url?(buffer, _) do
if Regex.match? @match_dots, buffer do
# IO.puts "..... '#{buffer}'"
if Regex.match? @invalid_url, buffer do
false
else
Regex.match? @match_url, buffer
end
end
@doc false
def match_phone(buffer) do
case Regex.scan @match_phone, buffer do
[] -> nil
other -> other
end
end
def link_phone(nil, buffer, _), do: buffer
def link_phone(list, buffer, opts) do
Builder.create_phone_link list, buffer, opts
end
@doc false
def link_url(true, buffer, opts) do
Builder.create_link(buffer, opts)

View file

@ -1,7 +1,7 @@
defmodule AutoLinker.Mixfile do
use Mix.Project
@version "0.1.1"
@version "0.2.0"
def project do
[
@ -29,7 +29,7 @@ defmodule AutoLinker.Mixfile do
# Dependencies can be Hex packages:
defp deps do
[
{:ex_doc, "~> 0.15", only: :dev},
{:ex_doc, "~> 0.18", only: :dev},
{:earmark, "~> 1.2", only: :dev, override: true},
]
end

View file

@ -1,2 +1,2 @@
%{"earmark": {:hex, :earmark, "1.2.0", "bf1ce17aea43ab62f6943b97bd6e3dc032ce45d4f787504e3adf738e54b42f3a", [:mix], []},
"ex_doc": {:hex, :ex_doc, "0.15.0", "e73333785eef3488cf9144a6e847d3d647e67d02bd6fdac500687854dd5c599f", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}}
%{"earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}}

View file

@ -3,4 +3,9 @@ defmodule AutoLinkerTest do
doctest AutoLinker
test "phone number" do
assert AutoLinker.link(", work (555) 555-5555", phone: true) ==
~s{, work <a href="#" class="phone-number" data-phone="5555555555">(555) 555-5555</a>}
end
end

24
test/builder_test.exs Normal file
View file

@ -0,0 +1,24 @@
defmodule AutoLinker.BuilderTest do
use ExUnit.Case
doctest AutoLinker.Builder
import AutoLinker.Builder
describe "create_phone_link" do
test "finishes" do
assert create_phone_link([], "", []) == ""
end
test "handles one link" do
phrase = "my exten is x888. Call me."
expected = ~s'my exten is <a href="#" class="phone-number" data-phone="888">x888</a>. Call me.'
assert create_phone_link([["x888", ""]], phrase, []) == expected
end
test "handles multiple links" do
phrase = "555.555.5555 or (555) 888-8888"
expected = ~s'<a href="#" class="phone-number" data-phone="5555555555">555.555.5555</a> or ' <>
~s'<a href="#" class="phone-number" data-phone="5558888888">(555) 888-8888</a>'
assert create_phone_link([["555.555.5555", ""], ["(555) 888-8888"]], phrase, []) == expected
end
end
end

View file

@ -4,7 +4,6 @@ defmodule AutoLinker.ParserTest do
import AutoLinker.Parser
describe "is_url" do
test "valid scheme true" do
valid_scheme_urls()
@ -32,6 +31,23 @@ defmodule AutoLinker.ParserTest do
end
end
describe "match_phone" do
test "valid" do
valid_phone_nunbers()
|> Enum.each(fn number ->
assert number |> match_phone() |> is_list
end)
end
test "invalid" do
invalid_phone_numbers()
|> Enum.each(fn number ->
assert number |> match_phone() |> is_nil
end)
end
end
describe "parse" do
test "does not link attributes" do
text = "Check out <a href='google.com'>google</a>"
@ -87,7 +103,27 @@ defmodule AutoLinker.ParserTest do
def invalid_non_scheme_urls, do: [
"invalid.com/perl.cgi?key= | web-site.com/cgi-bin/perl.cgi?key1=value1&key2",
"invalid.",
"hi..there"
"hi..there",
"555.555.5555"
]
def valid_phone_nunbers, do: [
"x55",
"x555",
"x5555",
"x12345",
"+1 555 555-5555",
"555 555-5555",
"555.555.5555",
"613-555-5555",
"1 (555) 555-5555",
"(555) 555-5555"
]
def invalid_phone_numbers, do: [
"5555",
"x5",
"(555) 555-55",
]
end