├── 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 | [![Build Status](https://travis-ci.org/smpallen99/auto_linker.png?branch=master)](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}' 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 google.com" 58 | assert parse(text) == text 59 | text = "Check out google.com" 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
google.com
" 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) <> "" <> 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 | --------------------------------------------------------------------------------