Packages

Text analysis and processing for Elixir including ngram, language detection and more.

Current section

Files

Jump to
text lib extract tld.ex
Raw

lib/extract/tld.ex

defmodule Text.Extract.Tld do
@moduledoc """
Top-level domain validation for `Text.Extract`.
At compile time, this module reads `priv/extract/tlds.txt` (the IANA
TLD list, refreshed by `mix text.download_tlds`) and bakes the entries
into a `MapSet` for O(1) lookup. The bundled file is committed to
source control; the mix task exists to make refreshes reproducible.
TLD comparison is case-insensitive and operates on the **ASCII** form
of a label. Internationalised TLDs in the IANA list are stored in
Punycode (`xn--…`) — pass labels through `Unicode.IDNA.to_ascii/2`
before lookup.
### Modes
* `:iana` — match against the full bundled IANA list (~1,440 entries).
* `:any` — accept any non-empty ASCII label (used by callers that
need to bypass TLD validation, e.g. for intranet hostnames or
ad-hoc strings).
Twitter-style tiered ccTLD/gTLD lists could be layered on top by a
caller, but in practice the IANA list and a "must end in a known TLD"
rule reproduce twitter-text's behaviour for every URL conformance
fixture we've checked: the TLDs that twitter-text rejects (e.g.
`.baz`, `.govedu`, `.comm`) are simply not in IANA either.
"""
@priv_path Application.app_dir(:text, "priv/extract/tlds.txt")
@external_resource @priv_path
@raw File.read!(@priv_path)
@tlds @raw
|> String.split(~r/\r?\n/, trim: true)
|> Enum.reject(&String.starts_with?(&1, "#"))
|> Enum.map(&String.downcase/1)
|> MapSet.new()
@version_line @raw
|> String.split(~r/\r?\n/, trim: true)
|> Enum.find(&String.starts_with?(&1, "#"))
# Pre-computed Unicode forms of every IDN TLD in the IANA list. Used
# by `Text.Extract.Scanner` to extend its bare-host regex with
# explicit alternatives for non-ASCII TLDs (e.g. `みんな`, `中国`,
# `рф`, `ไทย`) — without them, the scanner cannot tell where a
# CJK-followed-by-prose URL like `twitter.みんなです` should end.
#
# Built at compile time from the `xn--` ACE entries via
# `Unicode.IDNA.to_unicode/2`. Failures are skipped silently — they'd
# represent IANA list rows that fail current UTS #46 validation,
# which is rare but possible across Unicode versions.
@idn_tlds @tlds
|> Enum.filter(&String.starts_with?(&1, "xn--"))
|> Enum.flat_map(fn ace ->
case Unicode.IDNA.to_unicode(ace) do
{:ok, unicode} -> [unicode]
_ -> []
end
end)
|> Enum.uniq()
# ASCII TLDs sorted longest-first. Useful for callers that build
# regex alternations: `co.uk` vs `uk` — the regex must try `co` and
# `uk` separately at appropriate positions, but for a single-label
# alternation the order matters when one TLD is a prefix of another
# (`com` vs `comm` — `comm` isn't actually a TLD here, but the same
# principle applies to e.g. `co` vs `com`).
@ascii_tlds_sorted @tlds
|> Enum.reject(&String.starts_with?(&1, "xn--"))
|> Enum.sort_by(&{-String.length(&1), &1})
@doc """
Returns the IANA TLD list as a `MapSet` of lowercased ASCII labels.
### Examples
iex> "com" in Text.Extract.Tld.iana()
true
iex> "googleusercontent" in Text.Extract.Tld.iana()
false
"""
@spec iana() :: MapSet.t(String.t())
def iana, do: @tlds
@doc """
Returns the version header line from the bundled `tlds.txt`.
### Examples
iex> Text.Extract.Tld.version_line() =~ "Last Updated"
true
"""
@spec version_line() :: String.t() | nil
def version_line, do: @version_line
@doc """
Returns whether `label` is a known TLD under `mode`.
### Arguments
* `label` is an ASCII string. Pass IDN labels through
`Unicode.IDNA.to_ascii/2` first.
* `mode` is `:iana` (default) or `:any`.
### Returns
* `true` if the label is a known TLD under the mode, `false`
otherwise.
### Examples
iex> Text.Extract.Tld.tld?("com")
true
iex> Text.Extract.Tld.tld?("COM")
true
iex> Text.Extract.Tld.tld?("baz")
false
iex> Text.Extract.Tld.tld?("baz", :any)
true
"""
@spec tld?(String.t(), :iana | :any) :: boolean()
def tld?(label, mode \\ :iana)
def tld?(<<>>, _mode), do: false
def tld?(label, :any) when is_binary(label), do: true
def tld?(label, :iana) when is_binary(label) do
MapSet.member?(@tlds, String.downcase(label))
end
@doc """
Returns the ASCII TLDs sorted longest-first.
Useful for building regex alternations where longer TLDs must be
tried first.
### Examples
iex> ascii = Text.Extract.Tld.ascii_sorted()
iex> "com" in ascii
true
iex> "xn--p1ai" in Text.Extract.Tld.ascii_sorted()
false
"""
@spec ascii_sorted() :: [String.t()]
def ascii_sorted, do: @ascii_tlds_sorted
@doc """
Returns the IDN TLDs in their Unicode form.
Built at compile time from the `xn--` ACE entries by passing each
through `Unicode.IDNA.to_unicode/1`. Used by `Text.Extract.Scanner`
to extend its bare-host regex with explicit alternatives for IDN
TLDs.
### Examples
iex> tlds = Text.Extract.Tld.idn_unicode()
iex> length(tlds) > 100
true
iex> "みんな" in Text.Extract.Tld.idn_unicode()
true
"""
@spec idn_unicode() :: [String.t()]
def idn_unicode, do: @idn_tlds
@doc """
Returns the count of TLDs in the bundled IANA list.
### Examples
iex> Text.Extract.Tld.count() > 1000
true
"""
@spec count() :: non_neg_integer()
def count, do: MapSet.size(@tlds)
end