diff --git a/c_src/lazy_html.cpp b/c_src/lazy_html.cpp
index a9dc1a0..b21731a 100644
--- a/c_src/lazy_html.cpp
+++ b/c_src/lazy_html.cpp
@@ -821,6 +821,157 @@ std::vector tag(ErlNifEnv *env, ExLazyHTML ex_lazy_html) {
FINE_NIF(tag, 0);
+ExLazyHTML replace(ErlNifEnv *env, ExLazyHTML ex_lazy_html,
+ ErlNifBinary css_selector, ExLazyHTML ex_new_content) {
+ // Parse the CSS selector
+ auto parser = lxb_css_parser_create();
+ auto status = lxb_css_parser_init(parser, NULL);
+ if (status != LXB_STATUS_OK) {
+ throw std::runtime_error("failed to create css parser");
+ }
+ auto parser_guard =
+ ScopeGuard([&]() { lxb_css_parser_destroy(parser, true); });
+
+ auto css_selector_list = parse_css_selector(parser, css_selector);
+
+ // Find matching nodes
+ auto matching_nodes = std::vector();
+
+ for (auto node : ex_lazy_html.resource->nodes) {
+ auto selectors = lxb_selectors_create();
+ auto status = lxb_selectors_init(selectors);
+ if (status != LXB_STATUS_OK) {
+ throw std::runtime_error("failed to create selectors");
+ }
+ auto selectors_guard =
+ ScopeGuard([&]() { lxb_selectors_destroy(selectors, true); });
+
+ std::vector nodes_from_this_root;
+ status = lxb_selectors_find(
+ selectors, node, css_selector_list,
+ [](lxb_dom_node_t *node, lxb_css_selector_specificity_t spec,
+ void *ctx) -> lxb_status_t {
+ auto nodes =
+ reinterpret_cast *>(ctx);
+ nodes->push_back(node);
+ return LXB_STATUS_OK;
+ },
+ &nodes_from_this_root);
+ if (status != LXB_STATUS_OK) {
+ throw std::runtime_error("failed to run find");
+ }
+
+ matching_nodes.insert(matching_nodes.end(), nodes_from_this_root.begin(),
+ nodes_from_this_root.end());
+ }
+
+ // Check that exactly one node matches
+ if (matching_nodes.size() == 0) {
+ throw std::invalid_argument("no elements found matching selector");
+ }
+ if (matching_nodes.size() > 1) {
+ throw std::invalid_argument("expected exactly 1 element matching selector, but found " +
+ std::to_string(matching_nodes.size()));
+ }
+
+ auto target_node = matching_nodes[0];
+ auto parent_node = lxb_dom_node_parent(target_node);
+
+ if (parent_node == NULL) {
+ throw std::runtime_error("cannot replace root node");
+ }
+
+ // Insert all new content nodes before the target node
+ for (auto new_node : ex_new_content.resource->nodes) {
+ // Clone the node to avoid ownership issues
+ auto cloned_node = lxb_dom_node_clone(new_node, true);
+ if (cloned_node == NULL) {
+ throw std::runtime_error("failed to clone new content node");
+ }
+ lxb_dom_node_insert_before(target_node, cloned_node);
+ }
+
+ // Remove the target node
+ lxb_dom_node_remove(target_node);
+
+ // Return the original lazy_html (which has been modified in place)
+ return ex_lazy_html;
+}
+
+FINE_NIF(replace, ERL_NIF_DIRTY_JOB_CPU_BOUND);
+
+ExLazyHTML append_child(ErlNifEnv *env, ExLazyHTML ex_lazy_html,
+ ErlNifBinary css_selector, ExLazyHTML ex_child_content) {
+ // Parse the CSS selector
+ auto parser = lxb_css_parser_create();
+ auto status = lxb_css_parser_init(parser, NULL);
+ if (status != LXB_STATUS_OK) {
+ throw std::runtime_error("failed to create css parser");
+ }
+ auto parser_guard =
+ ScopeGuard([&]() { lxb_css_parser_destroy(parser, true); });
+
+ auto css_selector_list = parse_css_selector(parser, css_selector);
+
+ // Find matching nodes
+ auto selectors = lxb_selectors_create();
+ status = lxb_selectors_init(selectors);
+ if (status != LXB_STATUS_OK) {
+ throw std::runtime_error("failed to create selectors");
+ }
+ auto selectors_guard =
+ ScopeGuard([&]() { lxb_selectors_destroy(selectors, true); });
+
+ // Set selector options to match root nodes and get unique elements
+ lxb_selectors_opt_set(selectors, static_cast(
+ LXB_SELECTORS_OPT_MATCH_FIRST |
+ LXB_SELECTORS_OPT_MATCH_ROOT));
+
+ auto matching_nodes = std::vector();
+
+ for (auto node : ex_lazy_html.resource->nodes) {
+ status = lxb_selectors_find(
+ selectors, node, css_selector_list,
+ [](lxb_dom_node_t *node, lxb_css_selector_specificity_t spec,
+ void *ctx) -> lxb_status_t {
+ auto nodes =
+ reinterpret_cast *>(ctx);
+ nodes->push_back(node);
+ return LXB_STATUS_OK;
+ },
+ &matching_nodes);
+ if (status != LXB_STATUS_OK) {
+ throw std::runtime_error("failed to run find");
+ }
+ }
+
+ // Check that exactly one node matches
+ if (matching_nodes.size() == 0) {
+ throw std::invalid_argument("no elements found matching selector");
+ }
+ if (matching_nodes.size() > 1) {
+ throw std::invalid_argument("expected exactly 1 element matching selector, but found " +
+ std::to_string(matching_nodes.size()));
+ }
+
+ auto parent_node = matching_nodes[0];
+
+ // Append all child content nodes to the parent node
+ for (auto child_node : ex_child_content.resource->nodes) {
+ // Clone the node to avoid ownership issues
+ auto cloned_node = lxb_dom_node_clone(child_node, true);
+ if (cloned_node == NULL) {
+ throw std::runtime_error("failed to clone child content node");
+ }
+ lxb_dom_node_insert_child(parent_node, cloned_node);
+ }
+
+ // Return the original lazy_html (which has been modified in place)
+ return ex_lazy_html;
+}
+
+FINE_NIF(append_child, ERL_NIF_DIRTY_JOB_CPU_BOUND);
+
} // namespace lazy_html
FINE_INIT("Elixir.LazyHTML.NIF");
diff --git a/lib/lazy_html.ex b/lib/lazy_html.ex
index d814697..ea23778 100644
--- a/lib/lazy_html.ex
+++ b/lib/lazy_html.ex
@@ -481,6 +481,71 @@ defmodule LazyHTML do
LazyHTML.NIF.tag(lazy_html)
end
+ @doc ~S'''
+ Replaces the element matching the given CSS selector with new content.
+
+ The function expects exactly one element to match the selector. If no
+ element or more than one element matches, it raises an ArgumentError.
+
+ ## Examples
+
+ iex> lazy_html = LazyHTML.from_fragment(~S|Old content
|)
+ iex> new_content = LazyHTML.from_fragment(~S|New content
|)
+ iex> LazyHTML.replace(lazy_html, "#main span", new_content)
+ #LazyHTML<
+ 1 node
+ #1
+
+ >
+
+ iex> lazy_html = LazyHTML.from_fragment(~S||)
+ iex> new_content = LazyHTML.from_fragment(~S|Replaced item|)
+ iex> LazyHTML.replace(lazy_html, "#target", new_content)
+ #LazyHTML<
+ 1 node
+ #1
+ - Item 1
- Replaced item
- Item 3
+ >
+
+ '''
+ @spec replace(t(), String.t(), t()) :: t()
+ def replace(%LazyHTML{} = lazy_html, selector, %LazyHTML{} = new_content) when is_binary(selector) do
+ LazyHTML.NIF.replace(lazy_html, selector, new_content)
+ end
+
+ @doc ~S'''
+ Appends child content to the element matching the given CSS selector.
+
+ The function expects exactly one element to match the selector. If no
+ element or more than one element matches, it raises an ArgumentError.
+ The child content is appended as the last child(ren) of the matched element.
+
+ ## Examples
+
+ iex> lazy_html = LazyHTML.from_fragment(~S||)
+ iex> child_content = LazyHTML.from_fragment(~S|New child|)
+ iex> LazyHTML.appendChild(lazy_html, "#container", child_content)
+ #LazyHTML<
+ 1 node
+ #1
+ Existing content
New child
+ >
+
+ iex> lazy_html = LazyHTML.from_fragment(~S||)
+ iex> child_content = LazyHTML.from_fragment(~S|Item 2Item 3|)
+ iex> LazyHTML.appendChild(lazy_html, "#list", child_content)
+ #LazyHTML<
+ 1 node
+ #1
+
+ >
+
+ '''
+ @spec appendChild(t(), String.t(), t()) :: t()
+ def appendChild(%LazyHTML{} = lazy_html, selector, %LazyHTML{} = child_content) when is_binary(selector) do
+ LazyHTML.NIF.append_child(lazy_html, selector, child_content)
+ end
+
@doc ~S"""
Escapes the given string to make a valid HTML text.
diff --git a/lib/lazy_html/nif.ex b/lib/lazy_html/nif.ex
index e7098ac..efcccdf 100644
--- a/lib/lazy_html/nif.ex
+++ b/lib/lazy_html/nif.ex
@@ -27,6 +27,8 @@ defmodule LazyHTML.NIF do
def tag(_lazy_html), do: err!()
def nodes(_lazy_html), do: err!()
def num_nodes(_lazy_html), do: err!()
+ def replace(_lazy_html, _css_selector, _new_content), do: err!()
+ def append_child(_lazy_html, _css_selector, _child_content), do: err!()
defp err!(), do: :erlang.nif_error(:not_loaded)
end
diff --git a/test/lazy_html_test.exs b/test/lazy_html_test.exs
index 422a36e..20c475a 100644
--- a/test/lazy_html_test.exs
+++ b/test/lazy_html_test.exs
@@ -250,6 +250,154 @@ defmodule LazyHTMLTest do
end
end
+ describe "replace/3" do
+ test "replaces a single element with new content" do
+ lazy_html = LazyHTML.from_fragment(~S|Old content
|)
+ new_content = LazyHTML.from_fragment(~S|New content
|)
+
+ result = LazyHTML.replace(lazy_html, "#main span", new_content)
+
+ assert LazyHTML.to_html(result) == ~S||
+ end
+
+ test "replaces element in a list" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ new_content = LazyHTML.from_fragment(~S|Replaced item|)
+
+ result = LazyHTML.replace(lazy_html, "#target", new_content)
+
+ assert LazyHTML.to_html(result) == ~S|- Item 1
- Replaced item
- Item 3
|
+ end
+
+ test "replaces with multiple nodes" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ new_content = LazyHTML.from_fragment(~S|Title
New paragraph
|)
+
+ result = LazyHTML.replace(lazy_html, "#old", new_content)
+
+ assert LazyHTML.to_html(result) == ~S||
+ end
+
+ test "raises when no elements match" do
+ lazy_html = LazyHTML.from_fragment(~S|Content
|)
+ new_content = LazyHTML.from_fragment(~S|New content
|)
+
+ assert_raise ArgumentError, "no elements found matching selector", fn ->
+ LazyHTML.replace(lazy_html, "#nonexistent", new_content)
+ end
+ end
+
+ test "raises when multiple elements match" do
+ lazy_html = LazyHTML.from_fragment(~S|FirstSecond
|)
+ new_content = LazyHTML.from_fragment(~S|New content
|)
+
+ assert_raise ArgumentError, ~r/expected exactly 1 element matching selector.*but found 2/, fn ->
+ LazyHTML.replace(lazy_html, "span", new_content)
+ end
+ end
+
+ test "works with complex selectors" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ new_content = LazyHTML.from_fragment(~S|Updated item
|)
+
+ result = LazyHTML.replace(lazy_html, ".item.active", new_content)
+
+ assert LazyHTML.to_html(result) == ~S|Updated item
Inactive item
|
+ end
+
+ test "preserves document structure when replacing nested elements" do
+ lazy_html = LazyHTML.from_fragment(~S|Content|)
+ new_content = LazyHTML.from_fragment(~S|New Title
|)
+
+ result = LazyHTML.replace(lazy_html, "#title", new_content)
+
+ assert LazyHTML.to_html(result) == ~S|Content|
+ end
+ end
+
+ describe "appendChild/3" do
+ test "appends a single child to container" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ child_content = LazyHTML.from_fragment(~S|New child|)
+
+ result = LazyHTML.appendChild(lazy_html, "#container", child_content)
+
+ assert LazyHTML.to_html(result) == ~S|Existing content
New child |
+ end
+
+ test "appends multiple children to list" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ child_content = LazyHTML.from_fragment(~S|Item 2Item 3|)
+
+ result = LazyHTML.appendChild(lazy_html, "#list", child_content)
+
+ assert LazyHTML.to_html(result) == ~S||
+ end
+
+ test "appends to empty element" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ child_content = LazyHTML.from_fragment(~S|First content
|)
+
+ result = LazyHTML.appendChild(lazy_html, "#empty", child_content)
+
+ assert LazyHTML.to_html(result) == ~S||
+ end
+
+ test "appends mixed content types" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ child_content = LazyHTML.from_fragment(~S|Paragraph
|)
+
+ result = LazyHTML.appendChild(lazy_html, "#content", child_content)
+
+ assert LazyHTML.to_html(result) == ~S||
+ end
+
+ test "preserves existing children order" do
+ lazy_html = LazyHTML.from_fragment(~S|FirstSecond
|)
+ child_content = LazyHTML.from_fragment(~S|Third|)
+
+ result = LazyHTML.appendChild(lazy_html, ".parent", child_content)
+
+ assert LazyHTML.to_html(result) == ~S|FirstSecondThird
|
+ end
+
+ test "raises when no elements match" do
+ lazy_html = LazyHTML.from_fragment(~S|Content
|)
+ child_content = LazyHTML.from_fragment(~S|Child content
|)
+
+ assert_raise ArgumentError, "no elements found matching selector", fn ->
+ LazyHTML.appendChild(lazy_html, "#nonexistent", child_content)
+ end
+ end
+
+ test "raises when multiple elements match" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ child_content = LazyHTML.from_fragment(~S|Child content
|)
+
+ assert_raise ArgumentError, ~r/expected exactly 1 element matching selector.*but found 2/, fn ->
+ LazyHTML.appendChild(lazy_html, ".target", child_content)
+ end
+ end
+
+ test "works with complex selectors" do
+ lazy_html = LazyHTML.from_fragment(~S|Other
|)
+ child_content = LazyHTML.from_fragment(~S|Appended to main
|)
+
+ result = LazyHTML.appendChild(lazy_html, ".content.main", child_content)
+
+ assert LazyHTML.to_html(result) == ~S|Other
|
+ end
+
+ test "works with nested elements" do
+ lazy_html = LazyHTML.from_fragment(~S||)
+ child_content = LazyHTML.from_fragment(~S|New paragraph
|)
+
+ result = LazyHTML.appendChild(lazy_html, "#target", child_content)
+
+ assert LazyHTML.to_html(result) == ~S||
+ end
+ end
+
describe "query_by_id/2" do
test "raises when an empty id is given" do
assert_raise ArgumentError, ~r/id cannot be empty/, fn ->