using Xml; using Html; using Invercargill; using Invercargill.DataStructures; namespace Astralis { /// /// Represents an HTML element node in the DOM with convenient manipulation methods /// public class MarkupNode : GLib.Object { private Xml.Node* xml_node; private MarkupDocument document; internal MarkupNode(MarkupDocument doc, Xml.Node* node) { this.document = doc; this.xml_node = node; } /// /// The tag name of the element (e.g., "div", "span", "p") /// public string tag_name { owned get { return xml_node->name; } } /// /// The text content of this element and its children /// public string text_content { owned get { return xml_node->get_content() ?? ""; } set { xml_node->set_content(value); } } /// /// Gets an attribute value by name /// public string? get_attribute(string name) { return xml_node->get_prop(name); } /// /// Sets an attribute value /// public void set_attribute(string name, string value) { xml_node->set_prop(name, value); } /// /// Removes an attribute by name /// public void remove_attribute(string name) { xml_node->unset_prop(name); } /// /// Gets the id attribute of this element /// public string? id { owned get { return get_attribute("id"); } set { if (value != null) { set_attribute("id", value); } else { remove_attribute("id"); } } } /// /// Gets the class attribute as a string /// public string? class_string { owned get { return get_attribute("class"); } set { if (value != null) { set_attribute("class", value); } else { remove_attribute("class"); } } } /// /// Gets a list of CSS classes applied to this element /// public string[] classes { owned get { var class_attr = get_attribute("class"); if (class_attr == null || class_attr.strip() == "") { return new string[0]; } return class_attr.split_set(" \t\n\r"); } } /// /// Checks if this element has a specific CSS class /// public bool has_class(string class_name) { foreach (unowned var cls in classes) { if (cls == class_name) { return true; } } return false; } /// /// Adds a CSS class to this element /// public void add_class(string class_name) { if (has_class(class_name)) { return; } var current_classes = classes; var new_classes = new string[current_classes.length + 1]; for (int i = 0; i < current_classes.length; i++) { new_classes[i] = current_classes[i]; } new_classes[current_classes.length] = class_name; set_attribute("class", string.joinv(" ", new_classes)); } /// /// Removes a CSS class from this element /// public void remove_class(string class_name) { var current_classes = classes; var new_list = new StringBuilder(); bool first = true; bool found = false; foreach (unowned var cls in current_classes) { if (cls == class_name) { found = true; continue; } if (!first) { new_list.append(" "); } new_list.append(cls); first = false; } if (found) { if (new_list.len > 0) { set_attribute("class", new_list.str); } else { remove_attribute("class"); } } } /// /// Toggles a CSS class on this element /// public void toggle_class(string class_name) { if (has_class(class_name)) { remove_class(class_name); } else { add_class(class_name); } } /// /// Replaces all classes with a new set of classes /// public void set_classes(string[] new_classes) { set_attribute("class", string.joinv(" ", new_classes)); } /// /// Gets the parent element, or null if this is the root /// public MarkupNode? parent { owned get { var parent_node = xml_node->parent; if (parent_node == null || parent_node->type == ElementType.DOCUMENT_NODE) { return null; } return new MarkupNode(document, parent_node); } } /// /// Gets the first child element of this node /// public MarkupNode? first_element_child { owned get { var child = xml_node->first_element_child(); return child != null ? new MarkupNode(document, child) : null; } } /// /// Gets the last child element of this node /// public MarkupNode? last_element_child { owned get { var child = xml_node->last_element_child(); return child != null ? new MarkupNode(document, child) : null; } } /// /// Gets the next sibling element /// public MarkupNode? next_element_sibling { owned get { var sibling = xml_node->next_element_sibling(); return sibling != null ? new MarkupNode(document, sibling) : null; } } /// /// Gets the previous sibling element /// public MarkupNode? previous_element_sibling { owned get { var sibling = xml_node->previous_element_sibling(); return sibling != null ? new MarkupNode(document, sibling) : null; } } /// /// Gets all child elements of this node /// public MarkupNodeList children { owned get { var list = new MarkupNodeList(document); for (var child = xml_node->children; child != null; child = child->next) { if (child->type == ElementType.ELEMENT_NODE) { list.add_node(child); } } return list; } } /// /// Appends a new child element with the given tag name /// public MarkupNode append_child_element(string tag_name) { var new_node = xml_node->new_child(null, tag_name); return new MarkupNode(document, new_node); } /// /// Appends a text node to this element /// public void append_text(string text) { xml_node->add_content(text); } /// /// Creates and appends a child element with text content /// public MarkupNode append_child_with_text(string tag_name, string text) { var new_node = xml_node->new_text_child(null, tag_name, text); return new MarkupNode(document, new_node); } /// /// Removes this element from the DOM /// public void remove() { xml_node->unlink(); delete xml_node; } /// /// Removes all children from this element /// public void clear_children() { while (xml_node->children != null) { var child = xml_node->children; child->unlink(); delete child; } } /// /// Replaces this element with new content /// public void replace_with_html(string html) { // Parse the HTML fragment using HTML parser int options = (int)(Html.ParserOption.RECOVER | Html.ParserOption.NOERROR | Html.ParserOption.NOWARNING | Html.ParserOption.NOBLANKS | Html.ParserOption.NONET); string wrapped = "
" + html + "
"; char[] buffer = wrapped.to_utf8(); var temp_doc = Html.Doc.read_memory(buffer, buffer.length, "", null, options); if (temp_doc == null) { return; } var temp_root = temp_doc->get_root_element(); if (temp_root == null) { delete temp_doc; return; } // Import and insert children before this node var parent = xml_node->parent; if (parent != null) { for (var child = temp_root->children; child != null; child = child->next) { var imported = doc.import_node(child, true); parent->add_prev_sibling(imported); parent = imported; // Move insertion point } } // Remove this node remove(); delete temp_doc; } /// /// Sets the inner HTML of this element /// public string inner_html { owned set { clear_children(); if (value == null || value == "") { return; } // Parse the HTML fragment using HTML parser int options = (int)(Html.ParserOption.RECOVER | Html.ParserOption.NOERROR | Html.ParserOption.NOWARNING | Html.ParserOption.NOBLANKS | Html.ParserOption.NONET); string wrapped = "
" + value + "
"; char[] buffer = wrapped.to_utf8(); var temp_doc = Html.Doc.read_memory(buffer, buffer.length, "", null, options); if (temp_doc == null) { return; } var temp_root = temp_doc->get_root_element(); if (temp_root == null) { delete temp_doc; return; } // Import children for (var child = temp_root->children; child != null; child = child->next) { var imported = doc.import_node(child, true); xml_node->add_child(imported); } delete temp_doc; } owned get { // Use Html.Doc to serialize the children properly var temp_doc = new Html.Doc(); Xml.Node* wrapper = temp_doc.new_node(null, "div"); temp_doc.set_root_element(wrapper); // Copy children to the wrapper for (var child = xml_node->children; child != null; child = child->next) { var copied = child->copy(1); wrapper->add_child(copied); } // Serialize using HTML serializer string buffer; int len; temp_doc.dump_memory(out buffer, out len); // Extract just the inner content (between
and
) // The HTML serializer outputs proper HTML without XML declaration string result = buffer ?? ""; // Strip the wrapper div tags if (result.has_prefix("
")) { result = result.substring(5); } if (result.has_suffix("
")) { result = result.substring(0, result.length - 6); } return result; } } /// /// Gets the outer HTML of this element (including the element itself) /// public string outer_html { owned get { // Create a temporary HTML document to serialize this node var temp_doc = new Html.Doc(); temp_doc.set_root_element(xml_node->copy(1)); string buffer; int len; temp_doc.dump_memory(out buffer, out len); return buffer ?? ""; } } /// /// Creates an HttpResult from this node's outer HTML. /// Useful for returning HTML fragments to HTMX requests. /// /// HTTP status code (defaults to OK) /// An HttpResult containing the node's HTML with Content-Type text/html public HttpResult to_result(StatusCode status = StatusCode.OK) { return new HttpStringResult(outer_html, status) .set_header("Content-Type", "text/html; charset=UTF-8"); } internal MarkupDocument doc { get { return document; } } internal Xml.Node* native { get { return xml_node; } } } /// /// A list of HTML nodes supporting iteration and manipulation /// public class MarkupNodeList : Invercargill.Enumerable { private MarkupDocument document; private List nodes; internal MarkupNodeList(MarkupDocument doc) { this.document = doc; this.nodes = new List(); } internal void add_node(Xml.Node* node) { nodes.append(node); } /// /// Number of nodes in the list /// public int length { get { return (int)nodes.length(); } } /// /// Gets a node at the specified index /// public new MarkupNode? get(int index) { unowned List item = nodes.nth((uint)index); if (item == null) { return null; } return new MarkupNode(document, item.data); } /// /// Gets the first node, or null if empty /// public new MarkupNode? first { owned get { if (nodes.is_empty()) { return null; } unowned List item = nodes.first(); return item != null ? new MarkupNode(document, item.data) : null; } } /// /// Gets the last node, or null if empty /// public new MarkupNode? last { owned get { if (nodes.is_empty()) { return null; } unowned List item = nodes.last(); return item != null ? new MarkupNode(document, item.data) : null; } } /// /// Sets a property on all nodes in the list /// public void set_attribute_all(string name, string value) { foreach (var node in nodes) { node->set_prop(name, value); } } /// /// Adds a class to all nodes in the list /// public void add_class_all(string class_name) { foreach (var node in nodes) { var wrapper = new MarkupNode(document, node); wrapper.add_class(class_name); } } /// /// Removes a class from all nodes in the list /// public void remove_class_all(string class_name) { foreach (var node in nodes) { var wrapper = new MarkupNode(document, node); wrapper.remove_class(class_name); } } /// /// Sets text content on all nodes in the list /// public void set_text_all(string text) { foreach (var node in nodes) { node->set_content(text); } } /// /// Removes all nodes in the list from the DOM /// public void remove_all() { foreach (var node in nodes) { node->unlink(); delete node; } nodes = new List(); } /// /// Gets information about this enumerable /// public override Invercargill.EnumerableInfo get_info() { return new Invercargill.EnumerableInfo.infer_ultimate(this, Invercargill.EnumerableCategory.IN_MEMORY); } /// /// Gets a tracker for iterating over the nodes /// public override Invercargill.Tracker get_tracker() { return new NodeTracker(document, nodes); } /// /// Peeks at the count without full enumeration /// public override uint? peek_count() { return (uint)nodes.length(); } /// /// Tracker for iterating over MarkupNodeList /// private class NodeTracker : Invercargill.Tracker { private MarkupDocument document; private unowned List nodes; private unowned List? current; internal NodeTracker(MarkupDocument doc, List nodes) { this.document = doc; this.nodes = nodes; this.current = null; } public override MarkupNode get_next() { if (current == null) { current = nodes.first(); } else { current = current.next; } // has_next() should be called before get_next() // Returning null here would violate the non-nullable contract assert(current != null); return new MarkupNode(document, current.data); } public override bool has_next() { if (current == null) { return !nodes.is_empty(); } return current.next != null; } } } }