소스 검색

feat(document): add HTML document building support with libxml2

- Add DocumentModel.vala for HtmlDocument/HtmlNode functionality
- Add libxml2 dependency for XML/HTML processing
- Add add_endpoint convenience method to WebApplication
- Simplify SimpleApi example with new endpoint registration API
- Add DocumentBuilder example demonstrating dynamic HTML generation
Billy Barrow 1 주 전
부모
커밋
baaa1e5743
6개의 변경된 파일1492개의 추가작업 그리고 7개의 파일을 삭제
  1. 597 0
      examples/DocumentBuilder.vala
  2. 2 5
      examples/SimpleApi.vala
  3. 7 0
      examples/meson.build
  4. 1 0
      meson.build
  5. 882 0
      src/Document/DocumentModel.vala
  6. 3 2
      src/meson.build

+ 597 - 0
examples/DocumentBuilder.vala

@@ -0,0 +1,597 @@
+using Astralis;
+using Astralis.Document;
+using Invercargill;
+using Invercargill.DataStructures;
+
+/**
+ * DocumentBuilder Example
+ * 
+ * Demonstrates using the DocumentModel classes (HtmlDocument, HtmlNode) to
+ * programmatically build and manipulate HTML documents. Shows how to:
+ *   - Create HTML documents from scratch or from templates
+ *   - Use XPath selectors to find elements
+ *   - Manipulate DOM elements (add/remove classes, attributes, content)
+ *   - Handle form POST submissions with dynamic document updates
+ * 
+ * This example references patterns from:
+ *   - SimpleApi.vala: Basic endpoint structure
+ *   - FastResources.vala: Content type handling and response building
+ * 
+ * Usage: document-builder [port]
+ * 
+ * Examples:
+ *   document-builder
+ *   document-builder 8080
+ */
+
+// Simple task model for our todo list
+class TodoItem : Object {
+    public int id { get; set; }
+    public string title { get; set; }
+    public bool completed { get; set; }
+    
+    public TodoItem(int id, string title, bool completed = false) {
+        this.id = id;
+        this.title = title;
+        this.completed = completed;
+    }
+}
+
+// In-memory todo store
+class TodoStore : Object {
+    private Series<TodoItem> items = new Series<TodoItem>();
+    private int next_id = 1;
+    
+    public TodoStore() {
+        // Add some initial items
+        add("Learn Astralis DocumentModel");
+        add("Build dynamic HTML pages");
+        add("Handle form submissions");
+    }
+    
+    public void add(string title) {
+        items.add(new TodoItem(next_id++, title));
+    }
+    
+    public void toggle(int id) {
+        items.to_immutable_buffer().iterate((item) => {
+            if (item.id == id) {
+                item.completed = !item.completed;
+            }
+        });
+    }
+    
+    public void remove(int id) {
+        var new_items = items.to_immutable_buffer()
+            .where(item => item.id != id)
+            .to_series();
+        items = new_items;
+    }
+    
+    public ImmutableBuffer<TodoItem> all() {
+        return items.to_immutable_buffer();
+    }
+    
+    public int count() {
+        return (int)items.to_immutable_buffer().count();
+    }
+    
+    public int completed_count() {
+        return (int)items.to_immutable_buffer()
+            .count(item => item.completed);
+    }
+}
+
+// Main application with todo store
+TodoStore todo_store;
+
+// Home page endpoint - builds HTML document programmatically
+class HomePageEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        var doc = create_base_document("Document Builder Example");
+        
+        // Get the body element
+        var body = doc.body;
+        
+        // Add a header section
+        add_header_section(doc, body);
+        
+        // Add the todo list section
+        add_todo_list_section(doc, body);
+        
+        // Add the add todo form
+        add_form_section(doc, body);
+        
+        // Add a section showing DocumentModel features
+        add_features_section(doc, body);
+        
+        // Add footer
+        add_footer(doc, body);
+        
+        return doc.to_result();
+    }
+    
+    private HtmlDocument create_base_document(string title) throws Error {
+        // Create document from string template
+        var doc = new HtmlDocument.from_string("""
+            <!DOCTYPE html>
+            <html>
+            <head>
+                <meta charset="UTF-8"/>
+                <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+                <title></title>
+                <style>
+                    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
+                    .card { background: white; border-radius: 8px; padding: 20px; margin: 10px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
+                    h1 { color: #333; margin-top: 0; }
+                    h2 { color: #555; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }
+                    .todo-item { display: flex; align-items: center; padding: 10px; margin: 5px 0; background: #fafafa; border-radius: 4px; border-left: 4px solid #4CAF50; }
+                    .todo-item.completed { border-left-color: #ccc; opacity: 0.7; }
+                    .todo-item.completed .title { text-decoration: line-through; color: #888; }
+                    .todo-item .title { flex: 1; margin: 0 10px; }
+                    .todo-item form { margin: 0; }
+                    .stats { display: flex; gap: 20px; margin: 10px 0; }
+                    .stat { background: #e8f5e9; padding: 10px 20px; border-radius: 4px; }
+                    .stat-value { font-size: 24px; font-weight: bold; color: #4CAF50; }
+                    .stat-label { font-size: 12px; color: #666; }
+                    input[type="text"] { padding: 10px; border: 1px solid #ddd; border-radius: 4px; flex: 1; }
+                    button { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }
+                    .btn-primary { background: #4CAF50; color: white; }
+                    .btn-primary:hover { background: #45a049; }
+                    .btn-toggle { background: #2196F3; color: white; padding: 5px 10px; font-size: 12px; }
+                    .btn-delete { background: #f44336; color: white; padding: 5px 10px; font-size: 12px; }
+                    code { background: #e8e8e8; padding: 2px 6px; border-radius: 4px; font-size: 14px; }
+                    pre { background: #263238; color: #aed581; padding: 15px; border-radius: 4px; overflow-x: auto; }
+                    .feature { margin: 15px 0; }
+                    .feature code { display: block; background: #f5f5f5; padding: 10px; margin: 5px 0; }
+                    a { color: #2196F3; text-decoration: none; }
+                    a:hover { text-decoration: underline; }
+                    .empty { color: #999; font-style: italic; padding: 20px; text-align: center; }
+                </style>
+            </head>
+            <body></body>
+            </html>
+        """);
+        
+        // Set the title using the document's title property
+        doc.title = title;
+        
+        return doc;
+    }
+    
+    private void add_header_section(HtmlDocument doc, HtmlNode body) {
+        // Create header card
+        var header_card = body.append_child_element("div");
+        header_card.add_class("card");
+        
+        var h1 = header_card.append_child_with_text("h1", "📄 Document Builder Example");
+        
+        // Add description paragraph
+        var desc = header_card.append_child_element("p");
+        desc.append_text("This page demonstrates the ");
+        var code = desc.append_child_element("code");
+        code.append_text("HtmlDocument");
+        desc.append_text(" and ");
+        code = desc.append_child_element("code");
+        code.append_text("HtmlNode");
+        desc.append_text(" classes from the DocumentModel. The entire page is built programmatically!");
+        
+        // Add stats
+        var stats_div = header_card.append_child_element("div");
+        stats_div.add_class("stats");
+        
+        var total_stat = stats_div.append_child_element("div");
+        total_stat.add_class("stat");
+        var total_value = total_stat.append_child_with_text("div", todo_store.count().to_string());
+        total_value.add_class("stat-value");
+        var total_label = total_stat.append_child_with_text("div", "Total Tasks");
+        total_label.add_class("stat-label");
+        
+        var completed_stat = stats_div.append_child_element("div");
+        completed_stat.add_class("stat");
+        var completed_value = completed_stat.append_child_with_text("div", todo_store.completed_count().to_string());
+        completed_value.add_class("stat-value");
+        var completed_label = completed_stat.append_child_with_text("div", "Completed");
+        completed_label.add_class("stat-label");
+    }
+    
+    private void add_todo_list_section(HtmlDocument doc, HtmlNode body) {
+        var list_card = body.append_child_element("div");
+        list_card.add_class("card");
+        
+        var h2 = list_card.append_child_with_text("h2", "Todo List");
+        
+        var todos = todo_store.all();
+        var count = todo_store.count();
+        
+        if (count == 0) {
+            var empty = list_card.append_child_element("div");
+            empty.add_class("empty");
+            empty.append_text("No tasks yet! Add one below.");
+        } else {
+            todos.iterate((item) => {
+                var item_div = list_card.append_child_element("div");
+                item_div.add_class("todo-item");
+                if (item.completed) {
+                    item_div.add_class("completed");
+                }
+                
+                // Toggle form
+                var toggle_form = item_div.append_child_element("form");
+                toggle_form.set_attribute("method", "POST");
+                toggle_form.set_attribute("action", "/toggle");
+                toggle_form.set_attribute("style", "display: inline;");
+                
+                var hidden_id = toggle_form.append_child_element("input");
+                hidden_id.set_attribute("type", "hidden");
+                hidden_id.set_attribute("name", "id");
+                hidden_id.set_attribute("value", item.id.to_string());
+                
+                var toggle_btn = toggle_form.append_child_element("button");
+                toggle_btn.add_class("btn-toggle");
+                toggle_btn.set_attribute("type", "submit");
+                toggle_btn.append_text(item.completed ? "↩️ Undo" : "✓ Done");
+                
+                // Title span
+                var title_span = item_div.append_child_with_text("span", item.title);
+                title_span.add_class("title");
+                
+                // Delete form
+                var delete_form = item_div.append_child_element("form");
+                delete_form.set_attribute("method", "POST");
+                delete_form.set_attribute("action", "/delete");
+                delete_form.set_attribute("style", "display: inline;");
+                
+                var delete_hidden = delete_form.append_child_element("input");
+                delete_hidden.set_attribute("type", "hidden");
+                delete_hidden.set_attribute("name", "id");
+                delete_hidden.set_attribute("value", item.id.to_string());
+                
+                var delete_btn = delete_form.append_child_element("button");
+                delete_btn.add_class("btn-delete");
+                delete_btn.set_attribute("type", "submit");
+                delete_btn.append_text("🗑️ Delete");
+            });
+        }
+    }
+    
+    private void add_form_section(HtmlDocument doc, HtmlNode body) {
+        var form_card = body.append_child_element("div");
+        form_card.add_class("card");
+        
+        var h2 = form_card.append_child_with_text("h2", "Add New Task");
+        
+        // Create form
+        var form = form_card.append_child_element("form");
+        form.set_attribute("method", "POST");
+        form.set_attribute("action", "/add");
+        form.set_attribute("style", "display: flex; gap: 10px;");
+        
+        // Text input
+        var input = form.append_child_element("input");
+        input.set_attribute("type", "text");
+        input.set_attribute("name", "title");
+        input.set_attribute("placeholder", "Enter a new task...");
+        input.set_attribute("required", "required");
+        
+        // Submit button
+        var submit = form.append_child_element("button");
+        submit.add_class("btn-primary");
+        submit.set_attribute("type", "submit");
+        submit.append_text("Add Task");
+    }
+    
+    private void add_features_section(HtmlDocument doc, HtmlNode body) {
+        var features_card = body.append_child_element("div");
+        features_card.add_class("card");
+        
+        var h2 = features_card.append_child_with_text("h2", "DocumentModel Features Used");
+        
+        // Feature 1: Creating documents
+        var f1 = features_card.append_child_element("div");
+        f1.add_class("feature");
+        f1.append_child_with_text("strong", "Creating Documents:");
+        var code1 = f1.append_child_element("code");
+        code1.append_text("var doc = new HtmlDocument.from_string(html_template);");
+        
+        // Feature 2: XPath selectors
+        var f2 = features_card.append_child_element("div");
+        f2.add_class("feature");
+        f2.append_child_with_text("strong", "XPath Selectors:");
+        var code2 = f2.append_child_element("code");
+        code2.append_text("var element = doc.select_one(\"//div[@id='content']\");");
+        
+        // Feature 3: DOM manipulation
+        var f3 = features_card.append_child_element("div");
+        f3.add_class("feature");
+        f3.append_child_with_text("strong", "DOM Manipulation:");
+        var code3 = f3.append_child_element("code");
+        code3.append_text("element.add_class(\"highlight\"); element.set_attribute(\"data-id\", \"123\");");
+        
+        // Feature 4: Building elements
+        var f4 = features_card.append_child_element("div");
+        f4.add_class("feature");
+        f4.append_child_with_text("strong", "Building Elements:");
+        var code4 = f4.append_child_element("code");
+        code4.append_text("var child = parent.append_child_element(\"div\"); child.append_text(\"Hello!\");");
+        
+        // Feature 5: Returning HTML
+        var f5 = features_card.append_child_element("div");
+        f5.add_class("feature");
+        f5.append_child_with_text("strong", "Returning HTML Response:");
+        var code5 = f5.append_child_element("code");
+        code5.append_text("return doc.to_result();  // Returns HtmlResult with correct Content-Type");
+    }
+    
+    private void add_footer(HtmlDocument doc, HtmlNode body) {
+        var footer_card = body.append_child_element("div");
+        footer_card.add_class("card");
+        footer_card.set_attribute("style", "text-align: center; color: #666;");
+        
+        var p = footer_card.append_child_element("p");
+        p.append_text("Built with ");
+        var code = p.append_child_element("code");
+        code.append_text("Astralis.Document");
+        p.append_text(" - See ");
+        var link = p.append_child_element("a");
+        link.set_attribute("href", "https://github.com/example/astralis");
+        link.append_text("GitHub");
+        p.append_text(" for more examples.");
+    }
+}
+
+// Add todo endpoint
+class AddTodoEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        // Parse form data
+        FormData form_data = yield FormDataParser.parse(
+            context.request.request_body,
+            context.request.content_type
+        );
+        
+        var title = form_data.get_field("title");
+        
+        if (title != null && title.strip() != "") {
+            todo_store.add(title.strip());
+        }
+        
+        // Redirect back to home (302 Found)
+        return new HttpStringResult("", (StatusCode)302)
+            .set_header("Location", "/");
+    }
+}
+
+// Toggle todo endpoint
+class ToggleTodoEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        FormData form_data = yield FormDataParser.parse(
+            context.request.request_body,
+            context.request.content_type
+        );
+        
+        var id_str = form_data.get_field("id");
+        if (id_str != null) {
+            var id = int.parse(id_str);
+            todo_store.toggle(id);
+        }
+        
+        return new HttpStringResult("", (StatusCode)302)
+            .set_header("Location", "/");
+    }
+}
+
+// Delete todo endpoint
+class DeleteTodoEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        FormData form_data = yield FormDataParser.parse(
+            context.request.request_body,
+            context.request.content_type
+        );
+        
+        var id_str = form_data.get_field("id");
+        if (id_str != null) {
+            var id = int.parse(id_str);
+            todo_store.remove(id);
+        }
+        
+        return new HttpStringResult("", (StatusCode)302)
+            .set_header("Location", "/");
+    }
+}
+
+// API endpoint that returns JSON representation of todos
+class TodoJsonEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        var json_parts = new Series<string>();
+        json_parts.add("{\"todos\": [");
+        
+        bool first = true;
+        todo_store.all().iterate((item) => {
+            if (!first) json_parts.add(",");
+            var completed_str = item.completed ? "true" : "false";
+            var escaped_title = item.title.replace("\"", "\\\"");
+            json_parts.add(@"{\"id\":$(item.id),\"title\":\"$escaped_title\",\"completed\":$completed_str}");
+            first = false;
+        });
+        
+        json_parts.add("]}");
+        
+        var json = json_parts.to_immutable_buffer()
+            .aggregate<string>("", (acc, s) => acc + s);
+        
+        return new HttpStringResult(json)
+            .set_header("Content-Type", "application/json");
+    }
+}
+
+// XPath demo endpoint - shows how to use XPath selectors
+class XPathDemoEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        // Create a sample document
+        var doc = new HtmlDocument.from_string("""
+            <!DOCTYPE html>
+            <html>
+            <body>
+                <div id="header">
+                    <h1>Welcome</h1>
+                    <nav class="menu">
+                        <a href="/" class="link active">Home</a>
+                        <a href="/about" class="link">About</a>
+                    </nav>
+                </div>
+                <div id="content">
+                    <article class="post featured">
+                        <h2>First Post</h2>
+                        <p class="intro">This is the introduction.</p>
+                    </article>
+                    <article class="post">
+                        <h2>Second Post</h2>
+                        <p class="intro">Another introduction.</p>
+                    </article>
+                </div>
+            </body>
+            </html>
+        """);
+        
+        // Build a response showing various XPath queries
+        var result_doc = new HtmlDocument.from_string("""
+            <!DOCTYPE html>
+            <html>
+            <head>
+                <meta charset="UTF-8"/>
+                <title>XPath Demo</title>
+                <style>
+                    body { font-family: monospace; max-width: 900px; margin: 20px auto; padding: 20px; }
+                    .query { background: #f5f5f5; padding: 15px; margin: 10px 0; border-radius: 4px; }
+                    .xpath { color: #d73a49; font-weight: bold; }
+                    .result { background: #e8f5e9; padding: 10px; margin-top: 10px; border-radius: 4px; }
+                    pre { margin: 0; white-space: pre-wrap; }
+                    a { color: #2196F3; }
+                </style>
+            </head>
+            <body></body>
+            </html>
+        """);
+        
+        var body = result_doc.body;
+        
+        var h1 = body.append_child_with_text("h1", "XPath Selector Demo");
+        
+        var back = body.append_child_element("p");
+        var back_link = back.append_child_element("a");
+        back_link.set_attribute("href", "/");
+        back_link.append_text("← Back to Todo List");
+        
+        // Query 1: Select by ID
+        add_xpath_demo(result_doc, body, 
+            "Select element by ID",
+            "//*[@id='header']",
+            doc.select("//*[@id='header']"));
+        
+        // Query 2: Select by class
+        add_xpath_demo(result_doc, body,
+            "Select elements by class",
+            "//*[contains(@class, 'post')]",
+            doc.select("//*[contains(@class, 'post')]"));
+        
+        // Query 3: Select nested elements
+        add_xpath_demo(result_doc, body,
+            "Select all links in nav",
+            "//nav/a",
+            doc.select("//nav/a"));
+        
+        // Query 4: Select by multiple classes
+        add_xpath_demo(result_doc, body,
+            "Select featured posts",
+            "//*[contains(@class, 'featured')]",
+            doc.select("//*[contains(@class, 'featured')]"));
+        
+        // Query 5: Select h2 elements
+        add_xpath_demo(result_doc, body,
+            "Select all h2 headings",
+            "//h2",
+            doc.select("//h2"));
+        
+        return result_doc.to_result();
+    }
+    
+    private void add_xpath_demo(HtmlDocument doc, HtmlNode body, string title, string xpath, HtmlNodeList results) {
+        var query_div = body.append_child_element("div");
+        query_div.add_class("query");
+        
+        query_div.append_child_with_text("strong", title);
+        
+        var xpath_code = query_div.append_child_element("div");
+        xpath_code.add_class("xpath");
+        xpath_code.append_text(xpath);
+        
+        var result_div = query_div.append_child_element("div");
+        result_div.add_class("result");
+        
+        var count = results.length;
+        result_div.append_text(@"Found $count element(s):");
+        
+        var pre = result_div.append_child_element("pre");
+        if (count == 0) {
+            pre.append_text("(no matches)");
+        } else {
+            var results_text = new Series<string>();
+            var enumerator = results.iterator();
+            while (enumerator.move_next()) {
+                var node = enumerator.get();
+                if (node != null) {
+                    results_text.add(@"<$(node.tag_name)>");
+                }
+            }
+            pre.append_text(results_text.to_immutable_buffer()
+                .aggregate<string>("", (acc, s) => acc + (acc == "" ? "" : ", ") + s));
+        }
+    }
+}
+
+void main(string[] args) {
+    int port = args.length > 1 ? int.parse(args[1]) : 8080;
+    
+    // Initialize the todo store
+    todo_store = new TodoStore();
+    
+    print("╔══════════════════════════════════════════════════════════════╗\n");
+    print("║             Astralis DocumentBuilder Example                ║\n");
+    print("╠══════════════════════════════════════════════════════════════╣\n");
+    print(@"║  Port: $port");
+    for (int i = 0; i < 50 - port.to_string().length - 7; i++) print(" ");
+    print(" ║\n");
+    print("╠══════════════════════════════════════════════════════════════╣\n");
+    print("║  Endpoints:                                                  ║\n");
+    print("║    /           - Todo list (built with DocumentModel)        ║\n");
+    print("║    /add        - Add a todo item (POST)                      ║\n");
+    print("║    /toggle     - Toggle todo completion (POST)               ║\n");
+    print("║    /delete     - Delete a todo item (POST)                   ║\n");
+    print("║    /api/todos  - JSON API for todos                          ║\n");
+    print("║    /xpath      - XPath selector demo                         ║\n");
+    print("╚══════════════════════════════════════════════════════════════╝\n");
+    print("\nPress Ctrl+C to stop the server\n\n");
+    
+    try {
+        var application = new WebApplication(port);
+        
+        // Register compression components (optional, for better performance)
+        application.container.register_singleton<GzipCompressor>(() => new GzipCompressor());
+        application.container.register_singleton<ZstdCompressor>(() => new ZstdCompressor());
+        application.container.register_singleton<BrotliCompressor>(() => new BrotliCompressor());
+        
+        // Register endpoints using the pattern from SimpleApi.vala
+        application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));
+        application.add_endpoint<AddTodoEndpoint>(new EndpointRoute("/add"));
+        application.add_endpoint<ToggleTodoEndpoint>(new EndpointRoute("/toggle"));
+        application.add_endpoint<DeleteTodoEndpoint>(new EndpointRoute("/delete"));
+        application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));
+        application.add_endpoint<XPathDemoEndpoint>(new EndpointRoute("/xpath"));
+        
+        application.run();
+        
+    } catch (Error e) {
+        printerr("Error: %s\n", e.message);
+        Process.exit(1);
+    }
+}

+ 2 - 5
examples/SimpleApi.vala

@@ -21,11 +21,8 @@ class JsonEndpoint : Object, Endpoint {
 
 void main() {
     var application = new WebApplication(8080);
-    application.container.register_scoped<Endpoint>(() => new HelloEndpoint())
-        .with_metadata<EndpointRoute>(new EndpointRoute("/hello"));
-
-    application.container.register_scoped<Endpoint>(() => new JsonEndpoint())
-        .with_metadata<EndpointRoute>(new EndpointRoute("/json"));
+    application.add_endpoint<HelloEndpoint>(new EndpointRoute("/hello"));
+    application.add_endpoint<JsonEndpoint>(new EndpointRoute("/json"));
 
     application.run();
 }

+ 7 - 0
examples/meson.build

@@ -81,3 +81,10 @@ executable('fast-resources',
     dependencies: [astralis_dep, invercargill_dep],
     install: false
 )
+
+# DocumentBuilder Example - demonstrates HtmlDocument/HtmlNode for building dynamic HTML
+executable('document-builder',
+    'DocumentBuilder.vala',
+    dependencies: [astralis_dep, invercargill_dep],
+    install: false
+)

+ 1 - 0
meson.build

@@ -15,6 +15,7 @@ invercargill_json_dep = dependency('invercargill-json')
 zlib_dep = dependency('zlib')
 brotli_dep = dependency('libbrotlienc')
 zstd_dep = dependency('libzstd')
+libxml_dep = dependency('libxml-2.0')
 
 # VAPI Directory
 add_project_arguments(['--vapidir', join_paths(meson.current_source_dir(), 'vapi')], language: 'vala')

+ 882 - 0
src/Document/DocumentModel.vala

@@ -0,0 +1,882 @@
+using Xml;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Astralis.Document {
+
+    /// <summary>
+    /// Error domain for HTML document operations
+    /// </summary>
+    public errordomain HtmlError {
+        PARSE_ERROR,
+        FILE_NOT_FOUND,
+        INVALID_HTML,
+        NODE_NOT_FOUND,
+        INVALID_OPERATION
+    }
+
+    /// <summary>
+    /// Represents an HTML element node in the DOM with convenient manipulation methods
+    /// </summary>
+    public class HtmlNode : GLib.Object {
+        private Xml.Node* xml_node;
+        private HtmlDocument document;
+
+        internal HtmlNode(HtmlDocument doc, Xml.Node* node) {
+            this.document = doc;
+            this.xml_node = node;
+        }
+
+        /// <summary>
+        /// The tag name of the element (e.g., "div", "span", "p")
+        /// </summary>
+        public string tag_name {
+            owned get { return xml_node->name; }
+        }
+
+        /// <summary>
+        /// The text content of this element and its children
+        /// </summary>
+        public string text_content {
+            owned get { return xml_node->get_content() ?? ""; }
+            set { xml_node->set_content(value); }
+        }
+
+        /// <summary>
+        /// Gets an attribute value by name
+        /// </summary>
+        public string? get_attribute(string name) {
+            return xml_node->get_prop(name);
+        }
+
+        /// <summary>
+        /// Sets an attribute value
+        /// </summary>
+        public void set_attribute(string name, string value) {
+            xml_node->set_prop(name, value);
+        }
+
+        /// <summary>
+        /// Removes an attribute by name
+        /// </summary>
+        public void remove_attribute(string name) {
+            xml_node->unset_prop(name);
+        }
+
+        /// <summary>
+        /// Gets the id attribute of this element
+        /// </summary>
+        public string? id {
+            owned get { return get_attribute("id"); }
+            set {
+                if (value != null) {
+                    set_attribute("id", value);
+                } else {
+                    remove_attribute("id");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets the class attribute as a string
+        /// </summary>
+        public string? class_string {
+            owned get { return get_attribute("class"); }
+            set {
+                if (value != null) {
+                    set_attribute("class", value);
+                } else {
+                    remove_attribute("class");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets a list of CSS classes applied to this element
+        /// </summary>
+        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");
+            }
+        }
+
+        /// <summary>
+        /// Checks if this element has a specific CSS class
+        /// </summary>
+        public bool has_class(string class_name) {
+            foreach (unowned var cls in classes) {
+                if (cls == class_name) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /// <summary>
+        /// Adds a CSS class to this element
+        /// </summary>
+        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));
+        }
+
+        /// <summary>
+        /// Removes a CSS class from this element
+        /// </summary>
+        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");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Toggles a CSS class on this element
+        /// </summary>
+        public void toggle_class(string class_name) {
+            if (has_class(class_name)) {
+                remove_class(class_name);
+            } else {
+                add_class(class_name);
+            }
+        }
+
+        /// <summary>
+        /// Replaces all classes with a new set of classes
+        /// </summary>
+        public void set_classes(string[] new_classes) {
+            set_attribute("class", string.joinv(" ", new_classes));
+        }
+
+        /// <summary>
+        /// Gets the parent element, or null if this is the root
+        /// </summary>
+        public HtmlNode? parent {
+            owned get {
+                var parent_node = xml_node->parent;
+                if (parent_node == null || parent_node->type == ElementType.DOCUMENT_NODE) {
+                    return null;
+                }
+                return new HtmlNode(document, parent_node);
+            }
+        }
+
+        /// <summary>
+        /// Gets the first child element of this node
+        /// </summary>
+        public HtmlNode? first_element_child {
+            owned get {
+                var child = xml_node->first_element_child();
+                return child != null ? new HtmlNode(document, child) : null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the last child element of this node
+        /// </summary>
+        public HtmlNode? last_element_child {
+            owned get {
+                var child = xml_node->last_element_child();
+                return child != null ? new HtmlNode(document, child) : null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the next sibling element
+        /// </summary>
+        public HtmlNode? next_element_sibling {
+            owned get {
+                var sibling = xml_node->next_element_sibling();
+                return sibling != null ? new HtmlNode(document, sibling) : null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the previous sibling element
+        /// </summary>
+        public HtmlNode? previous_element_sibling {
+            owned get {
+                var sibling = xml_node->previous_element_sibling();
+                return sibling != null ? new HtmlNode(document, sibling) : null;
+            }
+        }
+
+        /// <summary>
+        /// Gets all child elements of this node
+        /// </summary>
+        public HtmlNodeList children {
+            owned get {
+                var list = new HtmlNodeList(document);
+                for (var child = xml_node->children; child != null; child = child->next) {
+                    if (child->type == ElementType.ELEMENT_NODE) {
+                        list.add_node(child);
+                    }
+                }
+                return list;
+            }
+        }
+
+        /// <summary>
+        /// Appends a new child element with the given tag name
+        /// </summary>
+        public HtmlNode append_child_element(string tag_name) {
+            var new_node = xml_node->new_child(null, tag_name);
+            return new HtmlNode(document, new_node);
+        }
+
+        /// <summary>
+        /// Appends a text node to this element
+        /// </summary>
+        public void append_text(string text) {
+            xml_node->add_content(text);
+        }
+
+        /// <summary>
+        /// Creates and appends a child element with text content
+        /// </summary>
+        public HtmlNode append_child_with_text(string tag_name, string text) {
+            var new_node = xml_node->new_text_child(null, tag_name, text);
+            return new HtmlNode(document, new_node);
+        }
+
+        /// <summary>
+        /// Removes this element from the DOM
+        /// </summary>
+        public void remove() {
+            xml_node->unlink();
+            delete xml_node;
+        }
+
+        /// <summary>
+        /// Removes all children from this element
+        /// </summary>
+        public void clear_children() {
+            while (xml_node->children != null) {
+                var child = xml_node->children;
+                child->unlink();
+                delete child;
+            }
+        }
+
+        /// <summary>
+        /// Replaces this element with new content
+        /// </summary>
+        public void replace_with_html(string html) {
+            // Parse the HTML fragment
+            var temp_doc = Parser.parse_memory("<div>" + html + "</div>", html.length);
+            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;
+        }
+
+        /// <summary>
+        /// Sets the inner HTML of this element
+        /// </summary>
+        public string inner_html {
+            owned set {
+                clear_children();
+                if (value == null || value == "") {
+                    return;
+                }
+                
+                // Parse the HTML fragment
+                var temp_doc = Parser.parse_memory("<div>" + value + "</div>", value.length);
+                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 {
+                var buffer = new StringBuilder();
+                for (var child = xml_node->children; child != null; child = child->next) {
+                    var child_content = child->get_content();
+                    if (child_content != null) {
+                        buffer.append(child_content);
+                    }
+                }
+                return buffer.str;
+            }
+        }
+
+        internal HtmlDocument doc {
+            get { return document; }
+        }
+
+        internal Xml.Node* native {
+            get { return xml_node; }
+        }
+    }
+
+    /// <summary>
+    /// A list of HTML nodes supporting iteration and manipulation
+    /// </summary>
+    public class HtmlNodeList : GLib.Object {
+        private HtmlDocument document;
+        private List<Xml.Node*> nodes;
+
+        internal HtmlNodeList(HtmlDocument doc) {
+            this.document = doc;
+            this.nodes = new List<Xml.Node*>();
+        }
+
+        internal void add_node(Xml.Node* node) {
+            nodes.append(node);
+        }
+
+        /// <summary>
+        /// Number of nodes in the list
+        /// </summary>
+        public int length {
+            get { return (int)nodes.length(); }
+        }
+
+        /// <summary>
+        /// Gets a node at the specified index
+        /// </summary>
+        public new HtmlNode? get(int index) {
+            unowned List<Xml.Node*> item = nodes.nth((uint)index);
+            if (item == null) {
+                return null;
+            }
+            return new HtmlNode(document, item.data);
+        }
+
+        /// <summary>
+        /// Gets the first node, or null if empty
+        /// </summary>
+        public HtmlNode? first {
+            owned get {
+                if (nodes.is_empty()) {
+                    return null;
+                }
+                unowned List<Xml.Node*> item = nodes.first();
+                return item != null ? new HtmlNode(document, item.data) : null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the last node, or null if empty
+        /// </summary>
+        public HtmlNode? last {
+            owned get {
+                if (nodes.is_empty()) {
+                    return null;
+                }
+                unowned List<Xml.Node*> item = nodes.last();
+                return item != null ? new HtmlNode(document, item.data) : null;
+            }
+        }
+
+        /// <summary>
+        /// Sets a property on all nodes in the list
+        /// </summary>
+        public void set_attribute_all(string name, string value) {
+            foreach (var node in nodes) {
+                node->set_prop(name, value);
+            }
+        }
+
+        /// <summary>
+        /// Adds a class to all nodes in the list
+        /// </summary>
+        public void add_class_all(string class_name) {
+            foreach (var node in nodes) {
+                var wrapper = new HtmlNode(document, node);
+                wrapper.add_class(class_name);
+            }
+        }
+
+        /// <summary>
+        /// Removes a class from all nodes in the list
+        /// </summary>
+        public void remove_class_all(string class_name) {
+            foreach (var node in nodes) {
+                var wrapper = new HtmlNode(document, node);
+                wrapper.remove_class(class_name);
+            }
+        }
+
+        /// <summary>
+        /// Sets text content on all nodes in the list
+        /// </summary>
+        public void set_text_all(string text) {
+            foreach (var node in nodes) {
+                node->set_content(text);
+            }
+        }
+
+        /// <summary>
+        /// Removes all nodes in the list from the DOM
+        /// </summary>
+        public void remove_all() {
+            foreach (var node in nodes) {
+                node->unlink();
+                delete node;
+            }
+            nodes = new List<Xml.Node*>();
+        }
+
+        /// <summary>
+        /// Gets an enumerator for iterating over the nodes
+        /// </summary>
+        public Enumerator iterator() {
+            return new Enumerator(document, nodes);
+        }
+
+        /// <summary>
+        /// Enumerator for iterating over HtmlNodeList
+        /// </summary>
+        public class Enumerator {
+            private HtmlDocument document;
+            private unowned List<Xml.Node*> nodes;
+            private unowned List<Xml.Node*>? current;
+
+            internal Enumerator(HtmlDocument doc, List<Xml.Node*> nodes) {
+                this.document = doc;
+                this.nodes = nodes;
+                this.current = null;
+            }
+
+            public bool move_next() {
+                if (current == null) {
+                    current = nodes.first();
+                } else {
+                    current = current.next;
+                }
+                return current != null;
+            }
+
+            public HtmlNode? get() {
+                if (current == null) {
+                    return null;
+                }
+                return new HtmlNode(document, current.data);
+            }
+
+            public void reset() {
+                current = null;
+            }
+        }
+    }
+
+    /// <summary>
+    /// Represents an HTML document that can be loaded, manipulated, and rendered
+    /// </summary>
+    public class HtmlDocument : GLib.Object {
+        private Xml.Doc* doc;
+
+        /// <summary>
+        /// Creates a new empty HTML document
+        /// </summary>
+        public HtmlDocument() {
+            doc = new Xml.Doc();
+            // Create basic HTML structure
+            var html = doc->new_node(null, "html");
+            doc->set_root_element(html);
+            
+            var head = html->new_child(null, "head");
+            var meta = head->new_child(null, "meta");
+            meta->set_prop("charset", "UTF-8");
+            head->new_child(null, "title");
+            
+            html->new_child(null, "body");
+        }
+
+        /// <summary>
+        /// Loads an HTML document from a file
+        /// </summary>
+        public HtmlDocument.from_file(string filepath) throws GLib.Error {
+            var file = File.new_for_path(filepath);
+            if (!file.query_exists()) {
+                throw new HtmlError.FILE_NOT_FOUND("File not found: %s".printf(filepath));
+            }
+            
+            uint8[] contents;
+            string etag_out;
+            file.load_contents(null, out contents, out etag_out);
+            
+            parse_html((string)contents);
+        }
+
+        /// <summary>
+        /// Loads an HTML document from a string
+        /// </summary>
+        public HtmlDocument.from_string(string html) throws GLib.Error {
+            parse_html(html);
+        }
+
+        private void parse_html(string html) throws GLib.Error {
+            // Use XML parser with HTML recovery options
+            // Note: libxml2's HTML parser is in a separate library
+            // For now, we'll use the XML parser with some tolerance
+            Parser.init();
+            
+            int options = (int)(ParserOption.RECOVER |
+                ParserOption.NOERROR |
+                ParserOption.NOWARNING |
+                ParserOption.NOBLANKS |
+                ParserOption.NONET);
+            
+            doc = Parser.read_memory(html, html.length, null, null, options);
+            
+            if (doc == null) {
+                // Try parsing as a fragment wrapped in a basic structure
+                string wrapped = "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"/></head><body>%s</body></html>".printf(html);
+                doc = Parser.read_memory(wrapped, wrapped.length, null, null, options);
+            }
+            
+            if (doc == null) {
+                throw new HtmlError.PARSE_ERROR("Failed to parse HTML document");
+            }
+        }
+
+        ~HtmlDocument() {
+            if (doc != null) {
+                delete doc;
+            }
+        }
+
+        /// <summary>
+        /// Gets the root html element of the document
+        /// </summary>
+        public HtmlNode? root {
+            owned get {
+                var root = doc->get_root_element();
+                return root != null ? new HtmlNode(this, root) : null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the head element of the document
+        /// </summary>
+        public HtmlNode? head {
+            owned get {
+                var root = doc->get_root_element();
+                if (root == null) return null;
+                
+                for (var child = root->children; child != null; child = child->next) {
+                    if (child->name == "head") {
+                        return new HtmlNode(this, child);
+                    }
+                }
+                return null;
+            }
+        }
+
+        /// <summary>
+        /// Gets the body element of the document
+        /// </summary>
+        public HtmlNode? body {
+            owned get {
+                var root = doc->get_root_element();
+                if (root == null) return null;
+                
+                for (var child = root->children; child != null; child = child->next) {
+                    if (child->name == "body") {
+                        return new HtmlNode(this, child);
+                    }
+                }
+                return null;
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the document title
+        /// </summary>
+        public string? title {
+            owned get {
+                var head_node = head;
+                if (head_node == null) return null;
+                
+                for (var child = head_node.native->children; child != null; child = child->next) {
+                    if (child->name == "title") {
+                        return child->get_content();
+                    }
+                }
+                return null;
+            }
+            set {
+                var head_node = head;
+                if (head_node == null) return;
+                
+                // Find existing title or create one
+                for (var child = head_node.native->children; child != null; child = child->next) {
+                    if (child->name == "title") {
+                        if (value != null) {
+                            child->set_content(value);
+                        } else {
+                            child->unlink();
+                            delete child;
+                        }
+                        return;
+                    }
+                }
+                
+                if (value != null) {
+                    head_node.native->new_text_child(null, "title", value);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Selects elements using an XPath expression
+        /// </summary>
+        public HtmlNodeList select(string xpath) {
+            var context = new XPath.Context(doc);
+            var result = context.eval(xpath);
+            
+            var list = new HtmlNodeList(this);
+            
+            if (result != null && result->nodesetval != null) {
+                var nodeset = result->nodesetval;
+                int len = nodeset->length();
+                for (int i = 0; i < len; i++) {
+                    var node = nodeset->item(i);
+                    if (node != null) {
+                        list.add_node(node);
+                    }
+                }
+            }
+            
+            delete result;
+            return list;
+        }
+
+        /// <summary>
+        /// Selects a single element using an XPath expression, returns first match or null
+        /// </summary>
+        public HtmlNode? select_one(string xpath) {
+            var results = select(xpath);
+            return results.first;
+        }
+
+        /// <summary>
+        /// Gets an element by its ID
+        /// </summary>
+        public HtmlNode? get_element_by_id(string id) {
+            return select_one("//*[@id='%s']".printf(id));
+        }
+
+        /// <summary>
+        /// Gets all elements with a specific tag name
+        /// </summary>
+        public HtmlNodeList get_elements_by_tag_name(string tag_name) {
+            return select("//%s".printf(tag_name));
+        }
+
+        /// <summary>
+        /// Gets all elements with a specific class name
+        /// </summary>
+        public HtmlNodeList get_elements_by_class_name(string class_name) {
+            return select("//*[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]".printf(class_name));
+        }
+
+        /// <summary>
+        /// Creates a new element with the given tag name
+        /// </summary>
+        public HtmlNode create_element(string tag_name) {
+            var node = doc->new_node(null, tag_name);
+            return new HtmlNode(this, node);
+        }
+
+        /// <summary>
+        /// Creates a new text node
+        /// </summary>
+        public Xml.Node* create_text_node(string text) {
+            return doc->new_text(text);
+        }
+
+        internal Xml.Node* import_node(Xml.Node* node, bool deep = true) {
+            return node->copy(deep ? 1 : 0);
+        }
+
+        /// <summary>
+        /// Converts the document to an HTML string
+        /// </summary>
+        public string to_html() {
+            string buffer;
+            doc->dump_memory(out buffer);
+            return buffer;
+        }
+
+        /// <summary>
+        /// Converts the document to a formatted HTML string with indentation
+        /// </summary>
+        public string to_pretty_html() {
+            string buffer;
+            int len;
+            doc->dump_memory_format(out buffer, out len, true);
+            return buffer;
+        }
+
+        /// <summary>
+        /// Saves the document to a file
+        /// </summary>
+        public void save(string filepath) throws GLib.Error {
+            doc->save_file_enc(filepath, "UTF-8");
+        }
+
+        /// <summary>
+        /// Creates an HttpResult from this document
+        /// </summary>
+        public HtmlResult to_result(StatusCode status = StatusCode.OK) {
+            return new HtmlResult(this, status);
+        }
+
+        /// <summary>
+        /// Sets the text content of an element by ID
+        /// </summary>
+        public bool set_element_text(string id, string text) {
+            var element = get_element_by_id(id);
+            if (element == null) {
+                return false;
+            }
+            element.text_content = text;
+            return true;
+        }
+
+        /// <summary>
+        /// Sets an attribute on an element by ID
+        /// </summary>
+        public bool set_element_attribute(string id, string attr_name, string attr_value) {
+            var element = get_element_by_id(id);
+            if (element == null) {
+                return false;
+            }
+            element.set_attribute(attr_name, attr_value);
+            return true;
+        }
+
+        /// <summary>
+        /// Adds a class to an element by ID
+        /// </summary>
+        public bool add_element_class(string id, string class_name) {
+            var element = get_element_by_id(id);
+            if (element == null) {
+                return false;
+            }
+            element.add_class(class_name);
+            return true;
+        }
+
+        /// <summary>
+        /// Removes a class from an element by ID
+        /// </summary>
+        public bool remove_element_class(string id, string class_name) {
+            var element = get_element_by_id(id);
+            if (element == null) {
+                return false;
+            }
+            element.remove_class(class_name);
+            return true;
+        }
+
+        /// <summary>
+        /// Sets the inner HTML of an element by ID
+        /// </summary>
+        public bool set_element_html(string id, string html) {
+            var element = get_element_by_id(id);
+            if (element == null) {
+                return false;
+            }
+            element.inner_html = html;
+            return true;
+        }
+
+        internal Xml.Doc* native_doc {
+            get { return doc; }
+        }
+    }
+
+    /// <summary>
+    /// An HttpResult that renders an HTML document
+    /// </summary>
+    public class HtmlResult : HttpResult {
+        private HtmlDocument document;
+
+        internal HtmlResult(HtmlDocument doc, StatusCode status = StatusCode.OK) {
+            base(status);
+            this.document = doc;
+            set_header("Content-Type", "text/html; charset=UTF-8");
+        }
+
+        /// <summary>
+        /// The HTML document being served
+        /// </summary>
+        public HtmlDocument html_document {
+            get { return document; }
+        }
+
+        public async override void send_body(AsyncOutput output) throws GLib.Error {
+            var html = document.to_html();
+            var bytes = new ByteBuffer.from_byte_array(html.data);
+            content_length = bytes.length;
+            yield output.write(bytes);
+        }
+    }
+}

+ 3 - 2
src/meson.build

@@ -20,16 +20,17 @@ sources = files(
     'Server/ResponseContext.vala',
     'Server/ServerInput.vala',
     'Server/ServerOutput.vala',
+    'Document/DocumentModel.vala',
 )
 
 libastralis = shared_library('astralis',
     sources,
-    dependencies: [glib_dep, gobject_dep, mhd_dep, gio_dep, gio_unix_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, zlib_dep, brotli_dep, zstd_dep, inversion_dep],
+    dependencies: [glib_dep, gobject_dep, mhd_dep, gio_dep, gio_unix_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, zlib_dep, brotli_dep, zstd_dep, inversion_dep, libxml_dep],
     install: true
 )
 
 astralis_dep = declare_dependency(
     link_with: libastralis,
     include_directories: include_directories('.'),
-    dependencies: [glib_dep, gobject_dep, invercargill_dep, invercargill_json_dep, mhd_dep, json_glib_dep, brotli_dep, zstd_dep, inversion_dep] # Users of astralis need glib, gobject, invercargill, mhd, brotli, and zstd
+    dependencies: [glib_dep, gobject_dep, invercargill_dep, invercargill_json_dep, mhd_dep, json_glib_dep, brotli_dep, zstd_dep, inversion_dep, libxml_dep] # Users of astralis need glib, gobject, invercargill, mhd, brotli, and zstd
 )