defmodule PureHTML do
@moduledoc """
A pure Elixir HTML5 parser with CSS selector querying.
PureHTML parses HTML strings into a tree of nodes and provides functions
to query, traverse, and serialize them back to HTML.
## Parsing
iex> PureHTML.parse("
"
## Node Format
Nodes are represented as tuples compatible with [Floki](https://hex.pm/packages/floki):
- `{tag, attrs, children}` - Element with tag name, attribute list, and children
- `{:doctype, name, public_id, system_id}` - DOCTYPE declaration
- `{:comment, text}` - HTML comment
- `"text"` - Text content (binary string)
Attributes are lists of `{name, value}` tuples, sorted alphabetically.
"""
alias PureHTML.{Query, Serializer, Tokenizer, TreeBuilder}
@doc """
Parses an HTML string into a list of nodes.
Returns a list of nodes where each node is one of:
- `{:doctype, name, public_id, system_id}` - DOCTYPE declaration (if present, always first)
- `{:comment, text}` - HTML comment
- `{tag, attrs, children}` - Element with tag name, attributes list, and child nodes
- `text` - Text content (binary)
Attributes are represented as a list of `{name, value}` tuples, sorted alphabetically.
## Examples
iex> PureHTML.parse("
Hello
")
[{"html", [], [{"head", [], []}, {"body", [], [{"p", [], ["Hello"]}]}]}]
iex> PureHTML.parse("")
[{:doctype, "html", nil, nil}, {"html", [], [{"head", [], []}, {"body", [], []}]}]
"""
@spec parse(String.t(), keyword()) :: [term()]
def parse(html, opts \\ []) when is_binary(html) do
case Keyword.get(opts, :context) do
nil ->
html
|> Tokenizer.new()
|> TreeBuilder.build()
context ->
{ns, tag} = parse_context(context)
html
|> Tokenizer.new(fragment_tokenizer_opts(ns, tag))
|> TreeBuilder.build_fragment(ns, tag)
end
end
@doc """
Converts parsed HTML nodes back to an HTML string.
## Options
- `:quote_char` - Force `"'"` or `"\""` for attribute quotes (default: smart quoting)
- `:minimize_boolean_attributes` - Output `disabled` vs `disabled=disabled` (default: true)
- `:use_trailing_solidus` - Output ` ` vs ` ` (default: false)
- `:escape_lt_in_attrs` - Escape `<` in attribute values (default: false)
- `:escape_rcdata` - Escape content in script/style (default: false)
- `:strip_whitespace` - Collapse whitespace in text nodes (default: false)
## Examples
iex> PureHTML.parse("
")
iex> PureHTML.query(html, "li")
[{"li", [], ["A"]}, {"li", [], ["B"]}]
"""
defdelegate query(html, selector), to: Query, as: :find
@doc """
Finds the first node matching the CSS selector.
Returns the first matching node, or `nil` if no match is found.
More efficient than `query/2` when you only need the first result.
## Examples
iex> "
"
...> |> PureHTML.parse()
...> |> PureHTML.query_one(".missing")
nil
"""
defdelegate query_one(html, selector), to: Query, as: :find_one
@doc """
Returns the immediate children of a node.
## Options
- `:include_text` - Include text nodes (default: true)
## Examples
iex> node = {"div", [], [{"p", [], ["Hello"]}, "Some text"]}
iex> PureHTML.children(node)
[{"p", [], ["Hello"]}, "Some text"]
iex> node = {"div", [], [{"p", [], ["Hello"]}, "Some text"]}
iex> PureHTML.children(node, include_text: false)
[{"p", [], ["Hello"]}]
"""
defdelegate children(node, opts \\ []), to: Query
@doc """
Extracts text content from an HTML tree or node.
## Options
- `:deep` - Traverse all descendants (default: true). When false, only direct text children.
- `:separator` - String to insert between text segments (default: "")
- `:strip` - Strip whitespace from each segment and remove empty segments (default: false)
- `:include_script` - Include text from `World")
iex> PureHTML.text(html)
"HelloWorld"
iex> html = PureHTML.parse("")
iex> PureHTML.text(html, include_inputs: true)
"test"
Formatted HTML often contains whitespace for indentation. Use `:strip` to clean it:
iex> html = PureHTML.parse("
\\n
One
\\n
Two
\\n
")
iex> PureHTML.text(html, strip: true, separator: " | ")
"One | Two"
"""
defdelegate text(html, opts \\ []), to: Query
@doc """
Extracts an attribute value from a single node.
Returns the attribute value as a string, or `nil` if the attribute
doesn't exist or the input is not an element.
## Examples
iex> node = {"a", [{"href", "/home"}, {"class", "link"}], ["Home"]}
iex> PureHTML.attr(node, "href")
"/home"
iex> node = {"a", [{"href", "/home"}], ["Home"]}
iex> PureHTML.attr(node, "title")
nil
iex> PureHTML.attr("text node", "href")
nil
"""
defdelegate attr(node, name), to: Query
@doc """
Extracts attribute values from a list of nodes.
Returns a list of attribute values. Nodes without the attribute are skipped.
## Examples
iex> "OneTwo"
...> |> PureHTML.parse()
...> |> PureHTML.query("a")
...> |> PureHTML.attribute("href")
["/one", "/two"]
"""
defdelegate attribute(nodes, name), to: Query
@doc """
Finds elements matching a selector and extracts an attribute from them.
Combines `query/2` and `attribute/2` into a single call for convenience.
This is the most common pattern for scraping.
## Examples
iex> ""
...> |> PureHTML.parse()
...> |> PureHTML.attribute("a", "href")
["/", "/about"]
iex> "
"
...> |> PureHTML.parse()
...> |> PureHTML.attribute("img", "src")
["a.png", "b.png"]
"""
defdelegate attribute(html, selector, name), to: Query
# --------------------------------------------------------------------------
# Fragment parsing helpers
# --------------------------------------------------------------------------
@raw_text_elements ~w(style xmp iframe noembed noframes noscript)
@rcdata_elements ~w(title textarea)
defp parse_context(context) do
case String.split(context, " ", parts: 2) do
["svg", tag] -> {:svg, tag}
["math", tag] -> {:math, tag}
[tag] -> {nil, tag}
end
end
# Foreign elements always use :data state — the tokenizer state rules
# only apply to HTML namespace context elements.
defp fragment_tokenizer_opts(ns, _tag) when ns in [:svg, :math], do: []
defp fragment_tokenizer_opts(_ns, tag) when tag in @rcdata_elements do
[initial_state: :rcdata]
end
defp fragment_tokenizer_opts(_ns, "script") do
[initial_state: :script_data]
end
defp fragment_tokenizer_opts(_ns, "plaintext") do
[initial_state: :plaintext]
end
defp fragment_tokenizer_opts(_ns, tag) when tag in @raw_text_elements do
[initial_state: :rawtext]
end
defp fragment_tokenizer_opts(_ns, _tag), do: []
end