defmodule Text.Extract do
@moduledoc """
Extract URLs and email addresses from arbitrary text at
social-media quality.
The extractor follows the rules that twitter-text uses (and which
Slack, Mastodon, and most "auto-link" implementations imitate),
layered with full UTS #46 IDNA processing for internationalised
domain names via the `:unicode_idna` package.
## Pipeline
1. **Scan** — `Text.Extract.Scanner` performs one linear pass over
the text and emits candidate spans. Boundary rules (twitter-text
§3) reject candidates immediately preceded by `$`, `_`, `@`, `#`,
`-`, `.`, or alphanumerics.
2. **Validate** — each candidate goes through `Text.Extract.Url` (or
`Text.Extract.Email` once that ships) for:
* RFC 3986 / RFC 5322 structural parsing.
* UTS #46 ToASCII for every host label (rejects DISALLOWED
codepoints, invalid hyphen positions, oversized labels, …).
* Twitter-style host-label rules — no leading/trailing
`-`/`_`, underscores forbidden in the registrable domain and
TLD labels.
* TLD lookup against `Text.Extract.Tld`'s bundled IANA list.
3. **Boundary cleanup** — `Text.Extract.Boundary` shrinks the span
to drop trailing punctuation and unbalanced brackets without
losing legitimate inner punctuation (Wikipedia-style URLs with
parentheses are preserved).
Each result is a map with the original Unicode form, the all-ASCII
Punycode form, byte offsets into the source, and the parsed RFC 3986
components.
## Phoenix integration
`Text.Extract.autolink/2` returns `Phoenix.HTML.safe()` (a
`{:safe, iodata}` tuple), so it drops directly into a Phoenix
template:
```eex
<%= Text.Extract.autolink(@user_post) %>
```
No `raw/1` needed — Phoenix knows the iodata is already escaped
appropriately. For non-Phoenix callers, convert with
`Phoenix.HTML.safe_to_string/1`.
## Examples
iex> Text.Extract.urls("see http://example.com today") |> Enum.map(& &1.url)
["http://example.com"]
iex> Text.Extract.urls("foo.com bar.net baz.org") |> Enum.map(& &1.url)
["foo.com", "bar.net", "baz.org"]
iex> Text.Extract.urls("see http://en.wikipedia.org/wiki/URI_(disambiguation).")
...> |> Enum.map(& &1.url)
["http://en.wikipedia.org/wiki/URI_(disambiguation)"]
"""
alias Text.Extract.{Email, Scanner, Url}
@doc """
Extracts URLs from `text`.
### Arguments
* `text` is a UTF-8 string.
### Options
* `:require_scheme` — when `true`, only `scheme://…` URLs validate;
schemeless candidates like `example.com` are rejected. Default
`false` (matches Twitter / auto-linking behaviour).
* `:schemes` — allowlist of accepted schemes. Default
`["http", "https", "ftp", "ftps"]`.
* `:tld_mode` — `:iana` (default) or `:any`. See `Text.Extract.Tld`.
* `:strict_idn` — when `true`, IDNA uses STD3 ASCII rules (rejects
`_` in labels). Default `false`.
### Returns
* A list of URL records in document order. Each record has
`:url`, `:ascii`, `:span`, `:scheme`, `:userinfo`, `:host`,
`:ascii_host`, `:port`, `:path`, `:query`, `:fragment`.
### Examples
iex> [r] = Text.Extract.urls("see http://example.com today")
iex> {r.url, r.host, r.span}
{"http://example.com", "example.com", {4, 18}}
iex> Text.Extract.urls("nothing here") |> length()
0
"""
@spec urls(String.t(), keyword()) :: [Url.url_record()]
def urls(text, options \\ []) when is_binary(text) do
text
|> Scanner.scan()
|> Enum.flat_map(fn
{:url, candidate, span} ->
case Url.validate(candidate, span, options) do
{:ok, record} -> [record]
{:error, _} -> []
end
_ ->
[]
end)
end
@doc """
Extracts email addresses from `text`.
### Arguments
* `text` is a UTF-8 string.
### Options
* `:eai` — when `true`, allow non-ASCII codepoints in the local part
per RFC 6531 SMTPUTF8 (e.g. `用户@example.com`). Default `true`.
* `:tld_mode` — `:iana` (default) or `:any`.
* `:strict_idn` — when `true`, IDNA uses STD3 ASCII rules. Default
`false`.
### Returns
* A list of email records in document order. Each record has
`:email`, `:ascii`, `:span`, `:local`, `:host`, `:ascii_host`.
### Examples
iex> [r] = Text.Extract.emails("Contact alice@example.com today.")
iex> {r.email, r.local, r.host}
{"alice@example.com", "alice", "example.com"}
iex> Text.Extract.emails("no email here") |> length()
0
"""
@spec emails(String.t(), keyword()) :: [Email.email_record()]
def emails(text, options \\ []) when is_binary(text) do
text
|> Scanner.scan()
|> Enum.flat_map(fn
{:email, candidate, span} ->
case Email.validate(candidate, span, options) do
{:ok, record} -> [record]
{:error, _} -> []
end
_ ->
[]
end)
end
@doc """
Extracts both URLs and email addresses from `text`, interleaved in
document order.
Where a candidate matches both — e.g. `mailto:alice@example.com` —
the email wins (it's the canonical interpretation).
### Arguments
* `text` is a UTF-8 string.
### Options
* Same options as `urls/2` and `emails/2` — `:require_scheme`,
`:schemes`, `:tld_mode`, `:eai`, `:strict_idn`, `:twitter_quirks`.
### Returns
* A list of records each with a `:kind` field (`:url` or `:email`)
plus the kind-specific fields. Sorted by `:span` start position.
### Examples
iex> [url, email] = Text.Extract.all("Visit https://example.com or email alice@example.com.")
iex> {url.kind, url.url, email.kind, email.email}
{:url, "https://example.com", :email, "alice@example.com"}
iex> Text.Extract.all("no links or emails here")
[]
"""
@spec all(String.t(), keyword()) :: [map()]
def all(text, options \\ []) when is_binary(text) do
text
|> Scanner.scan()
|> Enum.flat_map(fn
{:url, candidate, span} ->
case Url.validate(candidate, span, options) do
{:ok, r} -> [Map.put(r, :kind, :url)]
_ -> []
end
{:email, candidate, span} ->
case Email.validate(candidate, span, options) do
{:ok, r} -> [Map.put(r, :kind, :email)]
_ -> []
end
_ ->
[]
end)
end
@doc """
Splits `text` into a list of plain-string fragments and entity maps,
preserving document order.
This is the primitive `Text.Extract.autolink/2` is built on, and the
building block users typically want when rendering extracted text:
walk the list, leave strings alone, render entity maps however you
like (anchors, mentions, badges, link previews, …).
Adjacent fragments and entities concatenate to the original `text`
byte-for-byte, so round-tripping `Enum.map_join(split, &render/1)`
with the identity renderer reproduces the input exactly.
### Arguments
* `text` is a UTF-8 string.
### Options
* Same options as `all/2` — `:require_scheme`, `:schemes`,
`:tld_mode`, `:eai`, `:strict_idn`, `:twitter_quirks`.
### Returns
* A list whose elements are either `String.t()` (plain text segments)
or entity maps (`%{kind: :url, …}` / `%{kind: :email, …}`). Empty
string segments are omitted, so two adjacent entities appear with
no separator between them.
### Examples
iex> Text.Extract.split("Visit https://example.com today.")
...> |> Enum.map(fn
...> text when is_binary(text) -> {:text, text}
...> entity -> {entity.kind, entity.url || entity.email}
...> end)
[{:text, "Visit "}, {:url, "https://example.com"}, {:text, " today."}]
iex> Text.Extract.split("plain text only")
["plain text only"]
iex> Text.Extract.split("")
[]
"""
@spec split(String.t(), keyword()) :: [String.t() | map()]
def split(text, options \\ []) when is_binary(text) do
text
|> Scanner.scan()
|> Enum.map(fn
{:text, fragment} ->
fragment
{:url, candidate, span} ->
case Url.validate(candidate, span, options) do
{:ok, r} -> Map.put(r, :kind, :url)
{:error, _} -> candidate
end
{:email, candidate, span} ->
case Email.validate(candidate, span, options) do
{:ok, r} -> Map.put(r, :kind, :email)
{:error, _} -> candidate
end
end)
|> coalesce_strings()
end
# Adjacent strings appear when a candidate fails to validate and is
# promoted back to plain text. Coalesce them into a single fragment
# so downstream consumers see a clean alternation.
defp coalesce_strings(elements) do
elements
|> Enum.reduce([], fn
str, [prev | rest] when is_binary(str) and is_binary(prev) ->
[prev <> str | rest]
el, acc ->
[el | acc]
end)
|> Enum.reverse()
end
@doc """
Wraps URLs and email addresses in `text` with HTML `` anchors.
Plain-text segments are HTML-escaped. Anchor display text is the
*original* matched form (so `bücher.de` displays as `bücher.de`,
not its Punycode); the `href` uses the ASCII Punycode form for
URLs and `mailto:` plus the ASCII form for emails. This matches
what every modern browser does when a user clicks an IDN link.
For schemeless URLs (`example.com`) the `href` adds an explicit
scheme — defaults to `https` for safety; flip via `:href_scheme`.
### Arguments
* `text` is a UTF-8 string.
### Options
All `all/2` options pass through, plus:
* `:href_scheme` — `:https` (default) or `:http`. Used to construct
the `href` for schemeless URL matches like `example.com`.
* `:url_attrs` — extra HTML attributes (a keyword list) added to
every URL anchor. Defaults to `[]`. Common choices:
`[target: "_blank", rel: "noopener noreferrer nofollow"]`.
* `:email_attrs` — extra HTML attributes added to every email anchor.
* `:url_renderer` — `(entity -> iodata)` callback that fully overrides
URL rendering. When supplied, `:url_attrs` and `:href_scheme` are
ignored for URL output.
* `:email_renderer` — `(entity -> iodata)` callback that fully
overrides email rendering.
### Returns
* `Phoenix.HTML.safe()` — i.e. `{:safe, iodata}`. Drop the result
straight into a Phoenix template (`<%= autolink(text) %>`) and
Phoenix renders it without re-escaping. For non-Phoenix callers,
convert with `Phoenix.HTML.safe_to_string/1`.
### Examples
iex> "Visit https://example.com today."
...> |> Text.Extract.autolink()
...> |> Phoenix.HTML.safe_to_string()
~s|Visit https://example.com today.|
iex> "Email alice@example.com please."
...> |> Text.Extract.autolink()
...> |> Phoenix.HTML.safe_to_string()
~s|Email alice@example.com please.|
iex> "foo.com is a site."
...> |> Text.Extract.autolink()
...> |> Phoenix.HTML.safe_to_string()
...> |> String.contains?(~s|href="https://foo.com"|)
true
iex> "plain text & symbols < >"
...> |> Text.Extract.autolink()
...> |> Phoenix.HTML.safe_to_string()
"plain text & symbols < >"
"""
@spec autolink(String.t(), keyword()) :: Phoenix.HTML.safe()
def autolink(text, options \\ []) when is_binary(text) do
href_scheme = Keyword.get(options, :href_scheme, :https)
url_attrs = Keyword.get(options, :url_attrs, [])
email_attrs = Keyword.get(options, :email_attrs, [])
url_renderer = Keyword.get(options, :url_renderer)
email_renderer = Keyword.get(options, :email_renderer)
iodata =
text
|> split(options)
|> Enum.map(fn
str when is_binary(str) ->
{:safe, escaped} = Phoenix.HTML.html_escape(str)
escaped
%{kind: :url} = entity ->
if url_renderer do
entity |> url_renderer.() |> ensure_safe()
else
render_url(entity, href_scheme, url_attrs)
end
%{kind: :email} = entity ->
if email_renderer do
entity |> email_renderer.() |> ensure_safe()
else
render_email(entity, email_attrs)
end
end)
{:safe, iodata}
end
# Hand-builds an `` tag as iodata. URL hosts and paths are
# already URL-shaped (no raw markup) but we still escape via
# `Phoenix.HTML.html_escape/1` so any oddly-shaped query strings,
# IRI characters, or attribute values stay safe.
defp render_url(entity, href_scheme, attrs) do
href =
if entity.scheme do
entity.ascii
else
Atom.to_string(href_scheme) <> "://" <> entity.ascii
end
{:safe, href_safe} = Phoenix.HTML.html_escape(href)
{:safe, display_safe} = Phoenix.HTML.html_escape(entity.url)
[
~s||,
display_safe,
~s||
]
end
defp render_email(entity, attrs) do
{:safe, href_safe} = Phoenix.HTML.html_escape(entity.ascii)
{:safe, display_safe} = Phoenix.HTML.html_escape(entity.email)
[
~s||,
display_safe,
~s||
]
end
defp render_attrs([]), do: ""
defp render_attrs(attrs) do
Enum.map(attrs, fn {k, v} ->
{:safe, escaped} = Phoenix.HTML.html_escape(to_string(v))
[" ", to_string(k), ~s|="|, escaped, ~s|"|]
end)
end
# Custom renderers can return either an iodata-shaped value (raw,
# which we trust) or a `{:safe, iodata}` tuple (already-escaped).
# Strings get HTML-escaped — same as text segments — to prevent a
# well-intentioned `fn e -> "[#{e.url}]" end` from injecting markup.
defp ensure_safe({:safe, iodata}), do: iodata
defp ensure_safe(other) when is_binary(other) do
{:safe, escaped} = Phoenix.HTML.html_escape(other)
escaped
end
defp ensure_safe(iodata) when is_list(iodata), do: iodata
end