├── test
├── test_helper.exs
├── builder_test.exs
├── parser_test.exs
└── auto_linker_test.exs
├── .formatter.exs
├── .travis.yml
├── .gitignore
├── LICENSE
├── config
└── config.exs
├── mix.exs
├── README.md
├── lib
├── auto_linker.ex
└── auto_linker
│ ├── builder.ex
│ └── parser.ex
└── mix.lock
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
3 | ]
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: elixir
2 | elixir:
3 | - 1.4.2
4 | otp_release:
5 | - 18.2.1
6 | sudo: false
7 | notification:
8 | recipients:
9 | - smpallen99@yahoo.com
10 |
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
--------------------------------------------------------------------------------
/test/builder_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AutoLinker.BuilderTest do
2 | use ExUnit.Case
3 | doctest AutoLinker.Builder
4 |
5 | import AutoLinker.Builder
6 |
7 | describe "create_phone_link" do
8 | test "finishes" do
9 | assert create_phone_link([], "", []) == ""
10 | end
11 |
12 | test "handles one link" do
13 | phrase = "my exten is x888. Call me."
14 |
15 | expected =
16 | ~s'my exten is x888. Call me.'
17 |
18 | assert create_phone_link([["x888", ""]], phrase, []) == expected
19 | end
20 |
21 | test "handles multiple links" do
22 | phrase = "555.555.5555 or (555) 888-8888"
23 |
24 | expected =
25 | ~s'555.555.5555 or ' <>
26 | ~s'(555) 888-8888'
27 |
28 | assert create_phone_link([["555.555.5555", ""], ["(555) 888-8888"]], phrase, []) == expected
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 - 2020 E-MetroTel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | use Mix.Config
4 |
5 | # This configuration is loaded before any dependency and is restricted
6 | # to this project. If another project depends on this project, this
7 | # file won't be loaded nor affect the parent project. For this reason,
8 | # if you want to provide default values for your application for
9 | # 3rd-party users, it should be done in your "mix.exs" file.
10 |
11 | # You can configure for your application as:
12 | #
13 | # config :auto_linker, key: :value
14 | #
15 | # And access this configuration in your application as:
16 | #
17 | # Application.get_env(:auto_linker, :key)
18 | #
19 | # Or configure a 3rd-party app:
20 | #
21 | # config :logger, level: :info
22 | #
23 |
24 | # It is also possible to import configuration files, relative to this
25 | # directory. For example, you can emulate configuration per environment
26 | # by uncommenting the line below and defining dev.exs, test.exs and such.
27 | # Configuration from the imported file will override the ones defined
28 | # here (which is why it is important to import them last).
29 | #
30 | # import_config "#{Mix.env}.exs"
31 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule AutoLinker.Mixfile do
2 | use Mix.Project
3 |
4 | @version "1.0.0"
5 |
6 | def project do
7 | [
8 | app: :auto_linker,
9 | version: @version,
10 | elixir: "~> 1.4",
11 | build_embedded: Mix.env() == :prod,
12 | start_permanent: Mix.env() == :prod,
13 | deps: deps(),
14 | docs: [extras: ["README.md"]],
15 | test_coverage: [tool: ExCoveralls],
16 | preferred_cli_env: [
17 | coveralls: :test,
18 | "coveralls.detail": :test,
19 | "coveralls.post": :test,
20 | "coveralls.html": :test,
21 | test: :test
22 | ],
23 | package: package(),
24 | name: "AutoLinker",
25 | description: """
26 | AutoLinker is a basic package for turning website names into links.
27 | """
28 | ]
29 | end
30 |
31 | # Configuration for the OTP application
32 | def application do
33 | # Specify extra applications you'll use from Erlang/Elixir
34 | [extra_applications: [:logger]]
35 | end
36 |
37 | # Dependencies can be Hex packages:
38 | defp deps do
39 | [
40 | {:ex_doc, "~> 0.19", only: :dev},
41 | {:earmark, "~> 1.2", only: :dev, override: true},
42 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false},
43 | {:excoveralls, "~> 0.10", only: :test}
44 | ]
45 | end
46 |
47 | defp package do
48 | [
49 | maintainers: ["Stephen Pallen"],
50 | licenses: ["MIT"],
51 | links: %{"Github" => "https://github.com/smpallen99/auto_linker"},
52 | files: ~w(lib README.md mix.exs LICENSE)
53 | ]
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AutoLinker
2 |
3 | [](https://travis-ci.org/smpallen99/auto_linker) [![Hex Version][hex-img]][hex] [![License][license-img]][license]
4 |
5 | [hex-img]: https://img.shields.io/hexpm/v/auto_linker.svg
6 | [hex]: https://hex.pm/packages/auto_linker
7 | [license-img]: http://img.shields.io/badge/license-MIT-brightgreen.svg
8 | [license]: http://opensource.org/licenses/MIT
9 |
10 | AutoLinker is a basic package for turning website names, and phone numbers into links.
11 |
12 | Use this package in your web view to convert web references into click-able links.
13 |
14 | This is a very early version. Some of the described options are not yet functional.
15 |
16 | ## Installation
17 |
18 | The package can be installed by adding `auto_linker` to your list of dependencies in `mix.exs`:
19 |
20 | ```elixir
21 | def deps do
22 | [{:auto_linker, "~> 1.0"}]
23 | end
24 | ```
25 |
26 | ## Usage
27 |
28 | The following examples illustrate some examples on how to use the auto linker.
29 |
30 | iex> AutoLinker.link("google.com")
31 | "google.com"
32 |
33 | iex> AutoLinker.link("google.com", new_window: false, rel: false)
34 | "google.com"
35 |
36 | iex> AutoLinker.link("google.com", new_window: false, rel: false, class: false)
37 | "google.com"
38 |
39 | iex> AutoLinker.link("call me at x9999")
40 | ~s{call me at x9999}
41 |
42 | iex> AutoLinker.link("or at home on 555.555.5555")
43 | ~s{or at home on 555.555.5555}
44 |
45 | iex> AutoLinker.link(", work (555) 555-5555")
46 | ~s{, work (555) 555-5555}
47 |
48 | iex> AutoLinker.link("[Google Search](http://google.com)", markdown: true)
49 | "Google Search"
50 |
51 | See the [Docs](https://hexdocs.pm/auto_linker/) for more examples
52 |
53 | ## Configuration
54 |
55 | By default, link parsing is enabled and phone parsing is disabled.
56 |
57 | ```elixir
58 | # enable phone parsing, and disable link parsing
59 | config :auto_linker, opts: [phone: true, url: false]
60 | ```
61 |
62 |
63 | ## License
64 |
65 | `auto_linker` is Copyright (c) 2017 - 2020 E-MetroTel
66 |
67 | The source is released under the MIT License.
68 |
69 | Check [LICENSE](LICENSE) for more information.
70 |
--------------------------------------------------------------------------------
/lib/auto_linker.ex:
--------------------------------------------------------------------------------
1 | defmodule AutoLinker do
2 | @moduledoc """
3 | Create url links from text containing urls.
4 |
5 | Turns an input string like `"Check out google.com"` into
6 | `Check out "google.com"`
7 |
8 | ## Examples
9 |
10 | iex> AutoLinker.link("google.com")
11 | "google.com"
12 |
13 | iex> AutoLinker.link("google.com", new_window: false, rel: false)
14 | "google.com"
15 |
16 | iex> AutoLinker.link("google.com", new_window: false, rel: false, class: false)
17 | "google.com"
18 |
19 | iex> AutoLinker.link("[Google](http://google.com)", markdown: true, new_window: false, rel: false, class: false)
20 | "Google"
21 |
22 | iex> AutoLinker.link("[Google Search](http://google.com)", markdown: true)
23 | "Google Search"
24 |
25 | iex> AutoLinker.link("google.com", truncate: 12)
26 | "google.com"
27 |
28 | iex> AutoLinker.link("some-very-long-url.com", truncate: 12)
29 | "some-very-.."
30 |
31 | iex> AutoLinker.link("https://google.com", scheme: true)
32 | "google.com"
33 |
34 | iex> AutoLinker.link("https://google.com", scheme: true, strip_prefix: false)
35 | "https://google.com"
36 | """
37 |
38 | import AutoLinker.Parser
39 |
40 | @doc """
41 | Auto link a string.
42 |
43 | Options:
44 |
45 | * `class: "auto-linker"` - specify the class to be added to the generated link. false to clear
46 | * `rel: "noopener noreferrer"` - override the rel attribute. false to clear
47 | * `new_window: true` - set to false to remove `target='_blank'` attribute
48 | * `scheme: false` - Set to true to link urls with schema `http://google`
49 | * `truncate: false` - Set to a number to truncate urls longer then the number. Truncated urls will end in `..`
50 | * `strip_prefix: true` - Strip the scheme prefix
51 | * `exclude_class: false` - Set to a class name when you don't want urls auto linked in the html of the give class
52 | * `exclude_id: false` - Set to an element id when you don't want urls auto linked in the html of the give element
53 | * `exclude_patterns: ["```"] - Don't link anything between the the pattern
54 | * `markdown: false` - link markdown style links
55 |
56 | Each of the above options can be specified when calling `link(text, opts)`
57 | or can be set in the `:auto_linker's configuration. For example:
58 |
59 | config :auto_linker,
60 | class: false,
61 | new_window: false
62 |
63 | Note that passing opts to `link/2` will override the configuration settings.
64 | """
65 | def link(text, opts \\ []) do
66 | parse(text, opts)
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/auto_linker/builder.ex:
--------------------------------------------------------------------------------
1 | defmodule AutoLinker.Builder do
2 | @moduledoc """
3 | Module for building the auto generated link.
4 | """
5 |
6 | @doc """
7 | Create a link.
8 | """
9 | def create_link(url, opts) do
10 | []
11 | |> build_attrs(url, opts, :rel)
12 | |> build_attrs(url, opts, :target)
13 | |> build_attrs(url, opts, :class)
14 | |> build_attrs(url, opts, :scheme)
15 | |> format_url(url, opts)
16 | end
17 |
18 | def create_markdown_links(text, opts) do
19 | []
20 | |> build_attrs(text, opts, :rel)
21 | |> build_attrs(text, opts, :target)
22 | |> build_attrs(text, opts, :class)
23 | |> format_markdown(text, opts)
24 | end
25 |
26 | defp build_attrs(attrs, _, opts, :rel) do
27 | if rel = Map.get(opts, :rel, "noopener noreferrer"), do: [{:rel, rel} | attrs], else: attrs
28 | end
29 |
30 | defp build_attrs(attrs, _, opts, :target) do
31 | if Map.get(opts, :new_window, true), do: [{:target, :_blank} | attrs], else: attrs
32 | end
33 |
34 | defp build_attrs(attrs, _, opts, :class) do
35 | if cls = Map.get(opts, :class, "auto-linker"), do: [{:class, cls} | attrs], else: attrs
36 | end
37 |
38 | defp build_attrs(attrs, url, _opts, :scheme) do
39 | if String.starts_with?(url, ["http://", "https://"]),
40 | do: [{:href, url} | attrs],
41 | else: [{:href, "http://" <> url} | attrs]
42 | end
43 |
44 | defp format_url(attrs, url, opts) do
45 | url =
46 | url
47 | |> strip_prefix(Map.get(opts, :strip_prefix, true))
48 | |> truncate(Map.get(opts, :truncate, false))
49 |
50 | attrs = format_attrs(attrs)
51 | "" <> url <> ""
52 | end
53 |
54 | defp format_attrs(attrs) do
55 | attrs
56 | |> Enum.map(fn {key, value} -> ~s(#{key}='#{value}') end)
57 | |> Enum.join(" ")
58 | end
59 |
60 | defp format_markdown(attrs, text, _opts) do
61 | attrs =
62 | case format_attrs(attrs) do
63 | "" -> ""
64 | attrs -> " " <> attrs
65 | end
66 |
67 | Regex.replace(~r/\[(.+?)\]\((.+?)\)/, text, "\\1")
68 | end
69 |
70 | defp truncate(url, false), do: url
71 | defp truncate(url, len) when len < 3, do: url
72 |
73 | defp truncate(url, len) do
74 | if String.length(url) > len, do: String.slice(url, 0, len - 2) <> "..", else: url
75 | end
76 |
77 | defp strip_prefix(url, true) do
78 | url
79 | |> String.replace(~r/^https?:\/\//, "")
80 | |> String.replace(~r/^www\./, "")
81 | end
82 |
83 | defp strip_prefix(url, _), do: url
84 |
85 | def create_phone_link([], buffer, _) do
86 | buffer
87 | end
88 |
89 | def create_phone_link(list, buffer, opts) do
90 | list
91 | |> Enum.uniq()
92 | |> do_create_phone_link(buffer, opts)
93 | end
94 |
95 | def do_create_phone_link([], buffer, _opts) do
96 | buffer
97 | end
98 |
99 | def do_create_phone_link([h | t], buffer, opts) do
100 | do_create_phone_link(t, format_phone_link(h, buffer, opts), opts)
101 | end
102 |
103 | def format_phone_link([h | _], buffer, opts) do
104 | val =
105 | h
106 | |> String.replace(~r/[\.\+\- x\(\)]+/, "")
107 | |> format_phone_link(h, opts)
108 |
109 | String.replace(buffer, h, val)
110 | end
111 |
112 | def format_phone_link(number, original, opts) do
113 | tag = opts[:tag] || "a"
114 | class = opts[:class] || "phone-number"
115 | data_phone = opts[:data_phone] || "data-phone"
116 | attrs = format_attributes(opts[:attributes] || [])
117 | href = opts[:href] || "#"
118 |
119 | ~s'<#{tag} href="#{href}" class="#{class}" #{data_phone}="#{number}"#{attrs}>#{original}#{
120 | tag
121 | }>'
122 | end
123 |
124 | defp format_attributes(attrs) do
125 | Enum.reduce(attrs, "", fn {name, value}, acc ->
126 | acc <> ~s' #{name}="#{value}"'
127 | end)
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/test/parser_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AutoLinker.ParserTest do
2 | use ExUnit.Case
3 | doctest AutoLinker.Parser
4 |
5 | import AutoLinker.Parser
6 |
7 | describe "is_url" do
8 | test "valid scheme true" do
9 | valid_scheme_urls()
10 | |> Enum.each(fn url ->
11 | assert is_url?(url, true)
12 | end)
13 | end
14 |
15 | test "invalid scheme true" do
16 | invalid_scheme_urls()
17 | |> Enum.each(fn url ->
18 | refute is_url?(url, true)
19 | end)
20 | end
21 |
22 | test "valid scheme false" do
23 | valid_non_scheme_urls()
24 | |> Enum.each(fn url ->
25 | assert is_url?(url, false)
26 | end)
27 | end
28 |
29 | test "invalid scheme false" do
30 | invalid_non_scheme_urls()
31 | |> Enum.each(fn url ->
32 | refute is_url?(url, false)
33 | end)
34 | end
35 | end
36 |
37 | describe "match_phone" do
38 | test "valid" do
39 | valid_phone_nunbers()
40 | |> Enum.each(fn number ->
41 | assert number |> match_phone() |> valid_number?(number)
42 | end)
43 | end
44 |
45 | test "invalid" do
46 | invalid_phone_numbers()
47 | |> Enum.each(fn number ->
48 | assert number |> match_phone() |> is_nil
49 | end)
50 | end
51 | end
52 |
53 | describe "parse" do
54 | test "does not link attributes" do
55 | text = "Check out google"
56 | assert parse(text) == text
57 | text = "Check out
"
58 | assert parse(text) == text
59 | text = "Check out
"
60 | assert parse(text) == text
61 | end
62 |
63 | test "links url inside html" do
64 | text = "Check out
google.com
"
65 | expected = "Check out "
66 | assert parse(text, class: false, rel: false, new_window: false) == expected
67 | end
68 |
69 | test "excludes html with specified class" do
70 | text = "```Check out google.com
```"
71 | assert parse(text, exclude_pattern: "```") == text
72 | end
73 | end
74 |
75 | def valid_number?([list], number) do
76 | assert List.last(list) == number
77 | end
78 |
79 | def valid_number?(_, _), do: false
80 |
81 | def valid_scheme_urls,
82 | do: [
83 | "https://www.example.com",
84 | "http://www2.example.com",
85 | "http://home.example-site.com",
86 | "http://blog.example.com",
87 | "http://www.example.com/product",
88 | "http://www.example.com/products?id=1&page=2",
89 | "http://www.example.com#up",
90 | "http://255.255.255.255",
91 | "http://www.site.com:8008"
92 | ]
93 |
94 | def invalid_scheme_urls,
95 | do: [
96 | "http://invalid.com/perl.cgi?key= | http://web-site.com/cgi-bin/perl.cgi?key1=value1&key2"
97 | ]
98 |
99 | def valid_non_scheme_urls,
100 | do: [
101 | "www.example.com",
102 | "www2.example.com",
103 | "www.example.com:2000",
104 | "www.example.com?abc=1",
105 | "example.example-site.com",
106 | "example.com",
107 | "example.ca",
108 | "example.tv",
109 | "example.com:999?one=one",
110 | "255.255.255.255",
111 | "255.255.255.255:3000?one=1&two=2"
112 | ]
113 |
114 | def invalid_non_scheme_urls,
115 | do: [
116 | "invalid.com/perl.cgi?key= | web-site.com/cgi-bin/perl.cgi?key1=value1&key2",
117 | "invalid.",
118 | "hi..there",
119 | "555.555.5555"
120 | ]
121 |
122 | def valid_phone_nunbers,
123 | do: [
124 | "x55",
125 | "x555",
126 | "x5555",
127 | "x12345",
128 | "+1 555 555-5555",
129 | "555 555-5555",
130 | "555.555.5555",
131 | "613-555-5555",
132 | "1 (555) 555-5555",
133 | "(555) 555-5555",
134 | "1.555.555.5555",
135 | "800 555-5555",
136 | "1.800.555.5555",
137 | "1 (800) 555-5555",
138 | "888 555-5555",
139 | "887 555-5555",
140 | "1-877-555-5555",
141 | "1 800 710-5515"
142 | ]
143 |
144 | def invalid_phone_numbers,
145 | do: [
146 | "5555",
147 | "x5",
148 | "(555) 555-55"
149 | ]
150 | end
151 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"},
3 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"},
4 | "earmark": {:hex, :earmark, "1.4.9", "837e4c1c5302b3135e9955f2bbf52c6c52e950c383983942b68b03909356c0d9", [:mix], [{:earmark_parser, ">= 1.4.9", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "0d72df7d13a3dc8422882bed5263fdec5a773f56f7baeb02379361cb9e5b0d8e"},
5 | "earmark_parser": {:hex, :earmark_parser, "1.4.9", "819bda2049e6ee1365424e4ced1ba65806eacf0d2867415f19f3f80047f8037b", [:mix], [], "hexpm", "8bf54fddabf2d7e137a0c22660e71b49d5a0a82d1fb05b5af62f2761cd6485c4"},
6 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
7 | "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"},
8 | "excoveralls": {:hex, :excoveralls, "0.13.0", "4e1b7cc4e0351d8d16e9be21b0345a7e165798ee5319c7800b9138ce17e0b38e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "fe2a56c8909564e2e6764765878d7d5e141f2af3bc8ff3b018a68ee2a218fced"},
9 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"},
10 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"},
11 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"},
12 | "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"},
13 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"},
14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
15 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
16 | "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"},
17 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
18 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
19 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"},
20 | }
21 |
--------------------------------------------------------------------------------
/test/auto_linker_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AutoLinkerTest do
2 | use ExUnit.Case
3 | doctest AutoLinker
4 |
5 | test "phone number" do
6 | assert AutoLinker.link(", work (555) 555-5555", phone: true) ==
7 | ~s{, work (555) 555-5555}
8 | end
9 |
10 | test "multiple phone numbers" do
11 | assert AutoLinker.link("15555555555 and 15555555554", phone: true) ==
12 | ~s{15555555555 and } <>
13 | ~s{15555555554}
14 |
15 | assert AutoLinker.link("15555565222 and 15555565222", phone: true) ==
16 | ~s{15555565222 and } <>
17 | ~s{15555565222}
18 | end
19 |
20 | test "default link" do
21 | assert AutoLinker.link("google.com") ==
22 | "google.com"
23 | end
24 |
25 | test "markdown" do
26 | assert AutoLinker.link("[google.com](http://google.com)", markdown: true) ==
27 | "google.com"
28 | end
29 |
30 | test "does on link existing links" do
31 | assert AutoLinker.link("google.com") ==
32 | "google.com"
33 | end
34 |
35 | test "phone number and markdown link" do
36 | assert AutoLinker.link("888 888-8888 [ab](a.com)", phone: true, markdown: true) ==
37 | "888 888-8888" <>
38 | " ab"
39 | end
40 |
41 | describe "don't autolink code blocks in markdown" do
42 | test "auto link phone numbers in non md block" do
43 | text = "```\n5555555555```"
44 |
45 | assert AutoLinker.link(text, phone: true) ==
46 | String.replace(
47 | text,
48 | "5555555555",
49 | ~s'5555555555'
50 | )
51 | end
52 |
53 | test "autolink urls in non md block" do
54 | text = "```\ngoogle.com\n```"
55 |
56 | expected =
57 | String.replace(
58 | text,
59 | "google.com",
60 | "google.com"
61 | )
62 |
63 | assert AutoLinker.link(text) == expected
64 | end
65 |
66 | test "does not link phone numbers" do
67 | text = "!md\n5555555555 \n```\n5555555551\n```\nsomething\n```\ntest 5555555552\n```\n"
68 |
69 | assert AutoLinker.link(text, phone: true) ==
70 | String.replace(
71 | text,
72 | "5555555555",
73 | ~s'5555555555'
74 | )
75 | end
76 |
77 | test "does not add leading line" do
78 | text = "!md\n```\n5555555555\n```"
79 | assert AutoLinker.link(text, phone: true) == text
80 | end
81 |
82 | test "handles no terminating ``` block" do
83 | text = "!md\n```5555555555"
84 | assert AutoLinker.link(text, phone: true) == text
85 | end
86 |
87 | test "does not autolink urls in md block" do
88 | text = "!md\n```\ngoogle.com```"
89 | assert AutoLinker.link(text) == text
90 | end
91 | end
92 |
93 | describe "mixed links" do
94 | test "phone and link" do
95 | text = "test google.com @ x555"
96 |
97 | expected =
98 | "test google.com @ x555"
99 |
100 | assert AutoLinker.link(text, phone: true, rel: false, new_window: false, class: false) ==
101 | expected
102 | end
103 |
104 | test "no phone and link" do
105 | text = "test google.com @ x555"
106 | expected = "test google.com @ x555"
107 |
108 | assert AutoLinker.link(text, phone: false, rel: false, new_window: false, class: false) ==
109 | expected
110 | end
111 |
112 | test "phone and truncate 10" do
113 | text = "1-555-555-5555 and maps.google.com"
114 |
115 | expected =
116 | "1-555-555-5555 and maps.goo.."
117 |
118 | assert AutoLinker.link(text,
119 | phone: true,
120 | rel: false,
121 | new_window: false,
122 | class: false,
123 | truncate: 10
124 | ) == expected
125 | end
126 |
127 | test "phone and truncate 2" do
128 | text = "1-555-555-5555 and maps.google.com"
129 |
130 | expected =
131 | "1-555-555-5555 and maps.google.com"
132 |
133 | assert AutoLinker.link(text,
134 | phone: true,
135 | rel: false,
136 | new_window: false,
137 | class: false,
138 | truncate: 2
139 | ) == expected
140 | end
141 | end
142 |
143 | test "skips nested phone" do
144 | text = "test
"
145 | assert AutoLinker.link(text, phone: true, rel: false, new_window: false, class: false) == text
146 | end
147 |
148 | test "skips nested link" do
149 | text = "test
"
150 | assert AutoLinker.link(text, phone: true, rel: false, new_window: false, class: false) == text
151 | end
152 |
153 | test "skips phone number in div" do
154 | text = " x555
"
155 | assert AutoLinker.link(text, phone: true, rel: false, new_window: false, class: false) == text
156 | end
157 |
158 | test "skips link number in div" do
159 | text = " google.com
"
160 | assert AutoLinker.link(text, phone: true, rel: false, new_window: false, class: false) == text
161 | end
162 |
163 | test "does not skip phone number after div" do
164 | text = " x555
x555"
165 |
166 | expected =
167 | " x555
x555"
168 |
169 | assert AutoLinker.link(text, phone: true, rel: false, new_window: false, class: false) ==
170 | expected
171 | end
172 |
173 | test "skips links in nested tags" do
174 | text = ~s'google.com'
175 | assert AutoLinker.link(text, phone: true, rel: false, new_window: false, class: false) == text
176 | end
177 |
178 | test "url false" do
179 | text = "test google.com"
180 | assert AutoLinker.link(text, phone: true, url: false) == text
181 | assert AutoLinker.link(text, phone: false, url: false) == text
182 | end
183 |
184 | test "phone number with external line prefix" do
185 | text = "test 9 1 (613) 555-5555"
186 |
187 | assert AutoLinker.link(text, phone: true) ==
188 | "test 9 1 (613) 555-5555"
189 | end
190 |
191 | test "phone number with different external line prefixes" do
192 | valid = [
193 | "8 (613) 555-5555",
194 | "88 (613) 555-5555",
195 | "88-613-555-5555",
196 | "999.613.555.5555",
197 | "816135551234",
198 | "96135551234"
199 | ]
200 |
201 | Enum.each(valid, fn number ->
202 | stripped = String.replace(number, ~r/[\s.\-\(\)]/, "")
203 |
204 | escaped_number =
205 | number
206 | |> String.replace(~r/\(/, "\\(")
207 | |> String.replace(~r/\)/, "\\)")
208 | |> String.replace(~r/\-/, "\\-")
209 | |> String.replace(~r/\./, "\\.")
210 |
211 | assert Regex.match?(
212 | ~r/data-phone="#{stripped}">#{escaped_number}<\/a>/,
213 | AutoLinker.link(number, phone: true)
214 | )
215 | end)
216 | end
217 | end
218 |
--------------------------------------------------------------------------------
/lib/auto_linker/parser.ex:
--------------------------------------------------------------------------------
1 | defmodule AutoLinker.Parser do
2 | @moduledoc """
3 | Module to handle parsing the the input string.
4 | """
5 |
6 | alias AutoLinker.Builder
7 |
8 | @doc """
9 | Parse the given string, identifying items to link.
10 |
11 | Parses the string, replacing the matching urls and phone numbers with an html link.
12 |
13 | ## Examples
14 |
15 | iex> AutoLinker.Parser.parse("Check out google.com")
16 | "Check out google.com"
17 |
18 | iex> AutoLinker.Parser.parse("call me at x9999", phone: true)
19 | ~s{call me at x9999}
20 |
21 | iex> AutoLinker.Parser.parse("or at home on 555.555.5555", phone: true)
22 | ~s{or at home on 555.555.5555}
23 |
24 | iex> AutoLinker.Parser.parse(", work (555) 555-5555", phone: true)
25 | ~s{, work (555) 555-5555}
26 | """
27 |
28 | # @invalid_url ~r/\.\.+/
29 | @invalid_url ~r/(\.\.+)|(^(\d+\.){1,2}\d+$)/
30 |
31 | @match_url ~r{^[\w\.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$}
32 | @match_scheme ~r{^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$}
33 |
34 | @match_phone ~r"((?:x\d{2,7})|(?:(?:\d{1,3}[\s\-.]?)?(?:\+?1\s?(?:[.-]\s?)?)?(?:\(\s?(?:[2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\s?\)|(?:[2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\s?(?:[.-]\s?)?)(?:[2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\s?(?:[.-]\s?)?(?:[0-9]{4}))"
35 |
36 | @default_opts ~w(url)a
37 |
38 | def parse(text, opts \\ %{})
39 | def parse(text, list) when is_list(list), do: parse(text, Enum.into(list, %{}))
40 |
41 | def parse(text, opts) do
42 | config =
43 | :auto_linker
44 | |> Application.get_env(:opts, [])
45 | |> Enum.into(%{})
46 | |> Map.put(
47 | :attributes,
48 | Application.get_env(:auto_linker, :attributes, [])
49 | )
50 |
51 | opts =
52 | Enum.reduce(@default_opts, opts, fn opt, acc ->
53 | if is_nil(opts[opt]) and is_nil(config[opt]) do
54 | Map.put(acc, opt, true)
55 | else
56 | acc
57 | end
58 | end)
59 |
60 | opts = Map.merge(config, opts)
61 |
62 | text
63 | |> split_code_blocks()
64 | |> Enum.reduce([], fn
65 | {:skip, block}, acc ->
66 | [block | acc]
67 |
68 | block, acc ->
69 | [do_parse(block, opts) | acc]
70 | end)
71 | |> Enum.join("")
72 | end
73 |
74 | defp do_parse(text, %{phone: false} = opts), do: do_parse(text, Map.delete(opts, :phone))
75 | defp do_parse(text, %{url: false} = opts), do: do_parse(text, Map.delete(opts, :url))
76 |
77 | defp do_parse(text, %{phone: _} = opts) do
78 | text
79 | |> do_parse(false, opts, {"", "", :parsing}, &check_and_link_phone/3)
80 | |> do_parse(Map.delete(opts, :phone))
81 | end
82 |
83 | defp do_parse(text, %{markdown: true} = opts) do
84 | text
85 | |> Builder.create_markdown_links(opts)
86 | |> do_parse(Map.delete(opts, :markdown))
87 | end
88 |
89 | defp do_parse(text, %{url: _} = opts) do
90 | if (exclude = Map.get(opts, :exclude_pattern, false)) && String.starts_with?(text, exclude) do
91 | text
92 | else
93 | do_parse(text, Map.get(opts, :scheme, false), opts, {"", "", :parsing}, &check_and_link/3)
94 | end
95 | |> do_parse(Map.delete(opts, :url))
96 | end
97 |
98 | defp do_parse(text, _), do: text
99 |
100 | defp do_parse("", _scheme, _opts, {"", acc, _}, _handler),
101 | do: acc
102 |
103 | defp do_parse("", scheme, opts, {buffer, acc, _}, handler),
104 | do: acc <> handler.(buffer, scheme, opts)
105 |
106 | defp do_parse(" text, scheme, opts, {buffer, acc, :parsing}, handler),
107 | do: do_parse(text, scheme, opts, {"", acc <> buffer <> "" <> text, scheme, opts, {buffer, acc, :skip}, handler),
110 | do: do_parse(text, scheme, opts, {"", acc <> buffer <> "", :parsing}, handler)
111 |
112 | defp do_parse("<" <> text, scheme, opts, {"", acc, :parsing}, handler),
113 | do: do_parse(text, scheme, opts, {"<", acc, {:open, 1}}, handler)
114 |
115 | defp do_parse(">" <> text, scheme, opts, {buffer, acc, {:attrs, level}}, handler),
116 | do: do_parse(text, scheme, opts, {"", acc <> buffer <> ">", {:html, level}}, handler)
117 |
118 | defp do_parse(<> <> text, scheme, opts, {"", acc, {:attrs, level}}, handler),
119 | do: do_parse(text, scheme, opts, {"", acc <> <>, {:attrs, level}}, handler)
120 |
121 | defp do_parse("" <> text, scheme, opts, {buffer, acc, {:html, level}}, handler),
122 | do:
123 | do_parse(
124 | text,
125 | scheme,
126 | opts,
127 | {"", acc <> handler.(buffer, scheme, opts) <> "", {:close, level}},
128 | handler
129 | )
130 |
131 | defp do_parse(">" <> text, scheme, opts, {buffer, acc, {:close, 1}}, handler),
132 | do: do_parse(text, scheme, opts, {"", acc <> buffer <> ">", :parsing}, handler)
133 |
134 | defp do_parse(">" <> text, scheme, opts, {buffer, acc, {:close, level}}, handler),
135 | do: do_parse(text, scheme, opts, {"", acc <> buffer <> ">", {:html, level - 1}}, handler)
136 |
137 | defp do_parse(" " <> text, scheme, opts, {buffer, acc, {:open, level}}, handler),
138 | do: do_parse(text, scheme, opts, {"", acc <> buffer <> " ", {:attrs, level}}, handler)
139 |
140 | defp do_parse("\n" <> text, scheme, opts, {buffer, acc, {:open, level}}, handler),
141 | do: do_parse(text, scheme, opts, {"", acc <> buffer <> "\n", {:attrs, level}}, handler)
142 |
143 | # default cases where state is not important
144 | defp do_parse(" " <> text, scheme, %{phone: _} = opts, {buffer, acc, state}, handler),
145 | do: do_parse(text, scheme, opts, {buffer <> " ", acc, state}, handler)
146 |
147 | defp do_parse(" " <> text, scheme, opts, {buffer, acc, state}, handler),
148 | do:
149 | do_parse(
150 | text,
151 | scheme,
152 | opts,
153 | {"", acc <> handler.(buffer, scheme, opts) <> " ", state},
154 | handler
155 | )
156 |
157 | defp do_parse("\n" <> text, scheme, opts, {buffer, acc, state}, handler),
158 | do:
159 | do_parse(
160 | text,
161 | scheme,
162 | opts,
163 | {"", acc <> handler.(buffer, scheme, opts) <> "\n", state},
164 | handler
165 | )
166 |
167 | defp do_parse(<>, scheme, opts, {buffer, acc, state}, handler),
168 | do:
169 | do_parse(
170 | "",
171 | scheme,
172 | opts,
173 | {"", acc <> handler.(buffer <> <>, scheme, opts), state},
174 | handler
175 | )
176 |
177 | defp do_parse(<> <> text, scheme, opts, {buffer, acc, state}, handler),
178 | do: do_parse(text, scheme, opts, {buffer <> <>, acc, state}, handler)
179 |
180 | def check_and_link(buffer, scheme, opts) do
181 | buffer
182 | |> is_url?(scheme)
183 | |> link_url(buffer, opts)
184 | end
185 |
186 | def check_and_link_phone(buffer, _, opts) do
187 | buffer
188 | |> match_phone
189 | |> link_phone(buffer, opts)
190 | end
191 |
192 | @doc false
193 | def is_url?(buffer, true) do
194 | if Regex.match?(invalid_url_re(), buffer) do
195 | false
196 | else
197 | Regex.match?(match_scheme_re(), buffer)
198 | end
199 | end
200 |
201 | def is_url?(buffer, _) do
202 | if Regex.match?(invalid_url_re(), buffer) do
203 | false
204 | else
205 | Regex.match?(match_url_re(), buffer)
206 | end
207 | end
208 |
209 | @doc false
210 | def match_phone(buffer) do
211 | case Regex.scan(match_phone_re(), buffer) do
212 | [] -> nil
213 | other -> other
214 | end
215 | end
216 |
217 | defp invalid_url_re do
218 | :one_dialer
219 | |> Application.get_env(:invalid_url_re, @invalid_url)
220 | |> compile_re()
221 | end
222 |
223 | defp match_scheme_re do
224 | :one_dialer
225 | |> Application.get_env(:match_scheme_re, @match_scheme)
226 | |> compile_re()
227 | end
228 |
229 | defp match_url_re do
230 | :one_dialer
231 | |> Application.get_env(:match_url_re, @match_url)
232 | |> compile_re()
233 | end
234 |
235 | defp match_phone_re do
236 | :one_dialer
237 | |> Application.get_env(:match_phone_re, @match_phone)
238 | |> compile_re()
239 | end
240 |
241 | defp compile_re(string) when is_binary(string), do: Regex.compile!(string)
242 | defp compile_re(re), do: re
243 |
244 | def link_phone(nil, buffer, _), do: buffer
245 |
246 | def link_phone(list, buffer, opts) do
247 | Builder.create_phone_link(list, buffer, opts)
248 | end
249 |
250 | @doc false
251 | def link_url(true, buffer, opts) do
252 | Builder.create_link(buffer, opts)
253 | end
254 |
255 | def link_url(_, buffer, _opts), do: buffer
256 |
257 | def split_code_blocks(text) do
258 | if text =~ "!md" && text =~ "```" do
259 | split_code_blocks(text, [""], false)
260 | else
261 | [text]
262 | end
263 | end
264 |
265 | defp split_code_blocks("", acc, false) do
266 | acc
267 | end
268 |
269 | defp split_code_blocks("", [buff | acc], true) do
270 | [{:skip, buff} | acc]
271 | end
272 |
273 | defp split_code_blocks("```" <> rest, [buff | acc], true) do
274 | split_code_blocks(rest, ["", {:skip, buff <> "```"} | acc], false)
275 | end
276 |
277 | defp split_code_blocks("```" <> rest, acc, false) do
278 | split_code_blocks(rest, ["```" | acc], true)
279 | end
280 |
281 | defp split_code_blocks(<> <> rest, [buff | acc], in_block) do
282 | split_code_blocks(rest, [buff <> <> | acc], in_block)
283 | end
284 | end
285 |
--------------------------------------------------------------------------------