浏览代码

refactor(markup): switch from xml to html parser for document handling

Replace Xml.Doc and Xml.Parser with Html.Doc and Html.Doc.read_memory
to properly parse HTML documents instead of treating them as XML.

This improves handling of real-world HTML that may not be well-formed XML
and eliminates the need for manual XML declaration stripping.

- Use Html.Doc instead of Xml.Doc throughout markup classes
- Update parsing to use Html.Doc.read_memory with HTML parser options
- Remove strip_xml_declaration() as HTML serializer handles this
- Add HTMX example demonstrating the markup system
Billy Barrow 1 周之前
父节点
当前提交
d63f07386e
共有 5 个文件被更改,包括 875 次插入76 次删除
  1. 779 0
      examples/HtmxExample.vala
  2. 7 0
      examples/meson.build
  3. 21 38
      src/Markup/MarkupDocument.vala
  4. 53 25
      src/Markup/MarkupNode.vala
  5. 15 13
      src/Markup/MarkupTemplate.vala

+ 779 - 0
examples/HtmxExample.vala

@@ -0,0 +1,779 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+
+/**
+ * HTMX Example
+ * 
+ * Demonstrates using htmx (from CDN) with Astralis for dynamic content updates
+ * without full page reloads. Shows various htmx attributes and swap strategies:
+ *   - hx-get: Make GET requests to swap content
+ *   - hx-post: Make POST requests for actions
+ *   - hx-swap: Control how content is inserted (innerHTML, outerHTML, beforeend, etc.)
+ *   - hx-trigger: Control when requests are triggered (click, load, every, etc.)
+ *   - hx-target: Specify where to swap the response
+ *   - hx-indicator: Show loading states
+ * 
+ * htmx CDN: https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js
+ * 
+ * Usage: htmx-example [port]
+ */
+
+// Application state for the click counter
+class ClickState : Object {
+    public int click_count { get; set; }
+    public DateTime last_click { get; set; }
+    public string click_history { get; set; }
+    
+    public ClickState() {
+        click_count = 0;
+        last_click = new DateTime.now_local();
+        click_history = "";
+    }
+    
+    public void record_click(string button_name) {
+        click_count++;
+        last_click = new DateTime.now_local();
+        var timestamp = last_click.format("%H:%M:%S");
+        click_history = @"[$timestamp] $button_name\n" + click_history;
+        // Keep only last 10 entries
+        var lines = click_history.split("\n");
+        if (lines.length > 10) {
+            click_history = string.joinv("\n", lines[0:10]);
+        }
+    }
+}
+
+// Task list item
+class TaskItem : Object {
+    public int id { get; set; }
+    public string text { get; set; }
+    public bool completed { get; set; }
+    
+    public TaskItem(int id, string text) {
+        this.id = id;
+        this.text = text;
+        this.completed = false;
+    }
+}
+
+// Task manager state
+class TaskManager : Object {
+    private int next_id = 1;
+    public List<TaskItem> tasks;
+    
+    public TaskManager() {
+        tasks = new List<TaskItem>();
+        // Add some initial tasks
+        add_task("Learn htmx basics");
+        add_task("Build dynamic web apps");
+        add_task("Enjoy simple hypermedia");
+    }
+    
+    public TaskItem add_task(string text) {
+        var task = new TaskItem(next_id++, text);
+        tasks.append(task);
+        return task;
+    }
+    
+    public bool toggle_task(int id) {
+        foreach (var task in tasks) {
+            if (task.id == id) {
+                task.completed = !task.completed;
+                return true;
+            }
+        }
+        return false;
+    }
+    
+    public bool delete_task(int id) {
+        unowned List<TaskItem>? found_link = null;
+        unowned List<TaskItem>? link = tasks.first();
+        while (link != null) {
+            if (link.data.id == id) {
+                found_link = link;
+                break;
+            }
+            link = link.next;
+        }
+        if (found_link != null) {
+            tasks.delete_link(found_link);
+            return true;
+        }
+        return false;
+    }
+    
+    public TaskItem? get_task(int id) {
+        foreach (var task in tasks) {
+            if (task.id == id) {
+                return task;
+            }
+        }
+        return null;
+    }
+}
+
+/**
+ * CSS content for the htmx example page.
+ */
+private const string HTMX_CSS = """
+body {
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+    max-width: 900px;
+    margin: 0 auto;
+    padding: 20px;
+    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+    min-height: 100vh;
+    color: #eee;
+}
+.card {
+    background: rgba(255, 255, 255, 0.1);
+    backdrop-filter: blur(10px);
+    border-radius: 12px;
+    padding: 25px;
+    margin: 15px 0;
+    border: 1px solid rgba(255, 255, 255, 0.1);
+}
+h1, h2, h3 {
+    color: #fff;
+    margin-top: 0;
+}
+.htmx-logo {
+    font-size: 48px;
+    text-align: center;
+    margin-bottom: 10px;
+}
+.subtitle {
+    text-align: center;
+    color: #aaa;
+    margin-bottom: 30px;
+}
+.button-group {
+    display: flex;
+    gap: 10px;
+    flex-wrap: wrap;
+    justify-content: center;
+    margin: 20px 0;
+}
+button, .btn {
+    padding: 12px 24px;
+    border: none;
+    border-radius: 6px;
+    cursor: pointer;
+    font-size: 16px;
+    font-weight: 600;
+    transition: all 0.2s;
+    text-decoration: none;
+    display: inline-block;
+}
+button:hover, .btn:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+.btn-primary {
+    background: #3b82f6;
+    color: white;
+}
+.btn-primary:hover {
+    background: #2563eb;
+}
+.btn-success {
+    background: #10b981;
+    color: white;
+}
+.btn-success:hover {
+    background: #059669;
+}
+.btn-danger {
+    background: #ef4444;
+    color: white;
+}
+.btn-danger:hover {
+    background: #dc2626;
+}
+.btn-warning {
+    background: #f59e0b;
+    color: white;
+}
+.btn-warning:hover {
+    background: #d97706;
+}
+.btn-small {
+    padding: 6px 12px;
+    font-size: 14px;
+}
+.counter-display {
+    text-align: center;
+    padding: 30px;
+    background: rgba(0, 0, 0, 0.2);
+    border-radius: 8px;
+    margin: 20px 0;
+}
+.counter-value {
+    font-size: 72px;
+    font-weight: bold;
+    color: #3b82f6;
+    line-height: 1;
+}
+.info-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+    gap: 15px;
+    margin: 20px 0;
+}
+.info-item {
+    background: rgba(0, 0, 0, 0.2);
+    padding: 15px;
+    border-radius: 6px;
+    text-align: center;
+}
+.info-label {
+    font-size: 12px;
+    color: #aaa;
+    text-transform: uppercase;
+    letter-spacing: 1px;
+}
+.info-value {
+    font-size: 18px;
+    font-weight: 600;
+    color: #fff;
+    margin-top: 5px;
+}
+.task-list {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+}
+.task-item {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 12px;
+    background: rgba(0, 0, 0, 0.2);
+    border-radius: 6px;
+    margin-bottom: 8px;
+    transition: all 0.2s;
+}
+.task-item:hover {
+    background: rgba(0, 0, 0, 0.3);
+}
+.task-item.completed .task-text {
+    text-decoration: line-through;
+    color: #666;
+}
+.task-text {
+    flex: 1;
+}
+.task-actions {
+    display: flex;
+    gap: 5px;
+}
+.add-task-form {
+    display: flex;
+    gap: 10px;
+    margin-bottom: 20px;
+}
+.add-task-form input[type="text"] {
+    flex: 1;
+    padding: 12px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    border-radius: 6px;
+    background: rgba(0, 0, 0, 0.2);
+    color: #fff;
+    font-size: 16px;
+}
+.add-task-form input[type="text"]::placeholder {
+    color: #666;
+}
+.add-task-form input[type="text"]:focus {
+    outline: none;
+    border-color: #3b82f6;
+}
+.history-log {
+    background: rgba(0, 0, 0, 0.3);
+    border-radius: 6px;
+    padding: 15px;
+    font-family: monospace;
+    font-size: 13px;
+    max-height: 200px;
+    overflow-y: auto;
+    white-space: pre-wrap;
+    color: #10b981;
+}
+.loading-indicator {
+    display: none;
+    text-align: center;
+    padding: 10px;
+}
+.htmx-request .loading-indicator {
+    display: block;
+}
+.htmx-request .loading-indicator + .content {
+    opacity: 0.5;
+}
+.spinner {
+    border: 3px solid rgba(255, 255, 255, 0.1);
+    border-top: 3px solid #3b82f6;
+    border-radius: 50%;
+    width: 24px;
+    height: 24px;
+    animation: spin 1s linear infinite;
+    display: inline-block;
+}
+@keyframes spin {
+    0% { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+}
+code {
+    background: rgba(0, 0, 0, 0.3);
+    padding: 2px 6px;
+    border-radius: 4px;
+    font-size: 14px;
+    color: #f59e0b;
+}
+pre {
+    background: rgba(0, 0, 0, 0.3);
+    color: #aed581;
+    padding: 15px;
+    border-radius: 6px;
+    overflow-x: auto;
+    font-size: 13px;
+}
+.feature-list {
+    margin: 0;
+    padding-left: 20px;
+}
+.feature-list li {
+    margin: 8px 0;
+    color: #ccc;
+}
+.live-clock {
+    font-size: 24px;
+    font-family: monospace;
+    text-align: center;
+    padding: 15px;
+    background: rgba(0, 0, 0, 0.2);
+    border-radius: 6px;
+}
+.clicker-area {
+    text-align: center;
+    padding: 30px;
+    background: rgba(0, 0, 0, 0.1);
+    border-radius: 8px;
+    margin: 20px 0;
+    border: 2px dashed rgba(255, 255, 255, 0.2);
+}
+.clicker-area:hover {
+    border-color: #3b82f6;
+    background: rgba(59, 130, 246, 0.1);
+}
+""";
+
+/**
+ * HtmxTemplate - Main page template with htmx CDN script.
+ */
+class HtmxTemplate : MarkupTemplate {
+    protected override string get_markup() {
+        return """<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <title>htmx Example - Astralis</title>
+    <link rel="stylesheet" href="/styles.css"/>
+    <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
+</head>
+<body>
+    <div class="htmx-logo">⚡</div>
+    <p class="subtitle">htmx + Astralis: High Power Tools for HTML</p>
+    
+    <div class="card" id="main-card">
+        <h1>htmx Examples</h1>
+        <p>This page demonstrates <code>htmx</code> integration with Astralis. 
+        Content updates happen via AJAX without full page reloads.</p>
+    </div>
+    
+    <!-- Live Clock Example: Auto-updating content with hx-trigger="every 1s" -->
+    <div class="card">
+        <h2>🕐 Live Clock</h2>
+        <p>Auto-updates every second using <code>hx-trigger="every 1s"</code>:</p>
+        <div id="clock-container" class="live-clock"
+             hx-get="/clock" 
+             hx-trigger="load, every 1s">
+            Loading...
+        </div>
+    </div>
+    
+    <!-- Click Counter Example: Button clicks via hx-post -->
+    <div class="card">
+        <h2>👆 Click Counter</h2>
+        <p>Click buttons to update the counter. Uses <code>hx-post</code> and <code>hx-swap="innerHTML"</code>:</p>
+        
+        <div class="counter-display">
+            <div class="counter-value" id="counter-value">0</div>
+            <div class="button-group">
+                <button hx-post="/click/decrement" 
+                        hx-target="#counter-value" 
+                        hx-swap="innerHTML"
+                        class="btn-danger">− Decrement</button>
+                <button hx-post="/click/reset" 
+                        hx-target="#counter-value" 
+                        hx-swap="innerHTML"
+                        class="btn-warning">↺ Reset</button>
+                <button hx-post="/click/increment" 
+                        hx-target="#counter-value" 
+                        hx-swap="innerHTML"
+                        class="btn-success">+ Increment</button>
+            </div>
+        </div>
+        
+        <div class="info-grid">
+            <div class="info-item">
+                <div class="info-label">Last Click</div>
+                <div class="info-value" id="last-click">--:--:--</div>
+            </div>
+            <div class="info-item">
+                <div class="info-label">History</div>
+                <div class="info-value" id="click-history" style="font-size: 12px; font-family: monospace;">No clicks yet</div>
+            </div>
+        </div>
+        
+        <!-- Update history via hx-get on click -->
+        <div hx-get="/click/history" 
+             hx-trigger="click from:.button-group button"
+             hx-target="#click-history"
+             hx-swap="innerHTML"></div>
+        <div hx-get="/click/time" 
+             hx-trigger="click from:.button-group button"
+             hx-target="#last-click"
+             hx-swap="innerHTML"></div>
+    </div>
+    
+    <!-- Task List Example: CRUD operations with htmx -->
+    <div class="card">
+        <h2>✅ Task List</h2>
+        <p>A simple task manager with add, toggle, and delete operations:</p>
+        
+        <form class="add-task-form" hx-post="/tasks/add" hx-target="#task-list" hx-swap="innerHTML">
+            <input type="text" name="task" placeholder="Enter a new task..." required=""/>
+            <button type="submit" class="btn-primary">Add Task</button>
+        </form>
+        
+        <ul class="task-list" id="task-list">
+            <!-- Tasks loaded dynamically -->
+        </ul>
+        
+        <div hx-get="/tasks" hx-trigger="load" hx-target="#task-list" hx-swap="innerHTML"></div>
+    </div>
+    
+    <!-- Click Anywhere Example -->
+    <div class="card">
+        <h2>🎯 Click Area</h2>
+        <p>Click anywhere in the area below to record a click. Uses <code>hx-trigger="click"</code>:</p>
+        
+        <div class="clicker-area" 
+             hx-post="/area/click" 
+             hx-trigger="click"
+             hx-target="#area-result"
+             hx-swap="innerHTML">
+            <p>Click anywhere in this area!</p>
+            <div id="area-result" style="margin-top: 10px; font-size: 14px; color: #aaa;">
+                Clicks: 0
+            </div>
+        </div>
+    </div>
+    
+    <!-- How It Works -->
+    <div class="card">
+        <h2>How It Works</h2>
+        <p>htmx extends HTML with attributes that enable AJAX directly in your markup:</p>
+        
+        <pre><!-- Make a GET request and swap the response -->
+<div hx-get="/api/data" hx-swap="innerHTML">
+    Click to load
+</div>
+
+<!-- POST on click, target another element -->
+<button hx-post="/api/action" 
+        hx-target="#result"
+        hx-swap="outerHTML">
+    Submit
+</button>
+
+<!-- Poll every second -->
+<div hx-get="/clock" hx-trigger="every 1s">
+    Loading clock...
+</div></pre>
+        
+        <h3>Key htmx Attributes Used:</h3>
+        <ul class="feature-list">
+            <li><code>hx-get</code> - Make a GET request to the specified URL</li>
+            <li><code>hx-post</code> - Make a POST request to the specified URL</li>
+            <li><code>hx-trigger</code> - When to trigger the request (click, load, every Ns)</li>
+            <li><code>hx-target</code> - CSS selector for where to place the response</li>
+            <li><code>hx-swap</code> - How to swap content (innerHTML, outerHTML, beforeend)</li>
+        </ul>
+    </div>
+    
+    <div class="card">
+        <p style="text-align: center; color: #aaa; margin: 0;">
+            Built with <code>Astralis</code> + <code>htmx</code> | 
+            <a href="https://htmx.org/" target="_blank" style="color: #3b82f6;">htmx docs</a>
+        </p>
+    </div>
+</body>
+</html>
+""";
+    }
+}
+
+// Helper function to render tasks HTML
+string render_tasks_html() {
+    var sb = new StringBuilder();
+    foreach (var task in task_manager.tasks) {
+        string completed_class = task.completed ? "completed" : "";
+        string check_text = task.completed ? "✓" : "○";
+        string check_btn_class = task.completed ? "btn-success" : "btn-warning";
+        
+        sb.append(@"<li class=\"task-item $completed_class\">");
+        sb.append(@"<span class=\"task-text\">$(escape_html(task.text))</span>");
+        sb.append(@"<div class=\"task-actions\">");
+        sb.append(@"<button class=\"btn $check_btn_class btn-small\" ");
+        sb.append(@"hx-post=\"/tasks/$(task.id)/toggle\" ");
+        sb.append(@"hx-target=\"#task-list\" ");
+        sb.append(@"hx-swap=\"innerHTML\">$check_text</button> ");
+        sb.append(@"<button class=\"btn btn-danger btn-small\" ");
+        sb.append(@"hx-post=\"/tasks/$(task.id)/delete\" ");
+        sb.append(@"hx-target=\"#task-list\" ");
+        sb.append(@"hx-swap=\"innerHTML\">✕</button>");
+        sb.append(@"</div>");
+        sb.append(@"</li>");
+    }
+    return sb.str;
+}
+
+string escape_html(string text) {
+    var result = text.replace("&", "&amp;");
+    result = result.replace("<", "&lt;");
+    result = result.replace(">", "&gt;");
+    result = result.replace("\"", "&quot;");
+    return result;
+}
+
+// Main page endpoint
+class HomePageEndpoint : Object, Endpoint {
+    private HtmxTemplate template = Inversion.inject<HtmxTemplate>();
+    
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        var doc = template.new_instance();
+        return doc.to_result();
+    }
+}
+
+// Clock endpoint - returns just the time string
+class ClockEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        var now = new DateTime.now_local();
+        return new HttpStringResult(now.format("%H:%M:%S"), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Click counter increment endpoint
+class ClickIncrementEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        click_state.record_click("Increment");
+        return new HttpStringResult(click_state.click_count.to_string(), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Click counter decrement endpoint
+class ClickDecrementEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        click_state.record_click("Decrement");
+        return new HttpStringResult(click_state.click_count.to_string(), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Click counter reset endpoint
+class ClickResetEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        click_state.record_click("Reset");
+        click_state.click_count = 0;
+        return new HttpStringResult("0", StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Click history endpoint
+class ClickHistoryEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        if (click_state.click_history == "") {
+            return new HttpStringResult("No clicks yet", StatusCode.OK)
+                .set_header("Content-Type", "text/html; charset=utf-8");
+        }
+        // Return first line only for the small display
+        var lines = click_state.click_history.split("\n");
+        string first_line = lines.length > 0 ? lines[0] : "No clicks yet";
+        return new HttpStringResult(first_line, StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Click time endpoint
+class ClickTimeEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        return new HttpStringResult(click_state.last_click.format("%H:%M:%S"), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Area click counter
+int area_clicks = 0;
+
+class AreaClickEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        area_clicks++;
+        return new HttpStringResult(@"Clicks: $area_clicks", StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Task list endpoint - returns HTML for all tasks
+class TaskListEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        return new HttpStringResult(render_tasks_html(), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Add task endpoint
+class AddTaskEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        var form_data = yield FormDataParser.parse(
+            context.request.request_body,
+            context.request.content_type
+        );
+        
+        string? task_text = form_data.get_field("task");
+        
+        if (task_text != null && task_text.strip().length > 0) {
+            task_manager.add_task(task_text.strip());
+        }
+        
+        return new HttpStringResult(render_tasks_html(), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Toggle task endpoint
+class ToggleTaskEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route_info) throws Error {
+        string? id_str = null;
+        route_info.mapped_parameters.try_get("id", out id_str);
+        int task_id = int.parse(id_str ?? "0");
+        task_manager.toggle_task(task_id);
+        
+        return new HttpStringResult(render_tasks_html(), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Delete task endpoint
+class DeleteTaskEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext context, RouteContext route_info) throws Error {
+        string? id_str = null;
+        route_info.mapped_parameters.try_get("id", out id_str);
+        int task_id = int.parse(id_str ?? "0");
+        task_manager.delete_task(task_id);
+        
+        return new HttpStringResult(render_tasks_html(), StatusCode.OK)
+            .set_header("Content-Type", "text/html; charset=utf-8");
+    }
+}
+
+// Global state
+ClickState click_state;
+TaskManager task_manager;
+
+void main(string[] args) {
+    int port = args.length > 1 ? int.parse(args[1]) : 8080;
+    
+    // Initialize state
+    click_state = new ClickState();
+    task_manager = new TaskManager();
+    
+    print("╔══════════════════════════════════════════════════════════════╗\n");
+    print("║              Astralis htmx 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("║    /              - Main page with htmx examples             ║\n");
+    print("║    /styles.css    - CSS stylesheet (FastResource)            ║\n");
+    print("║    /clock         - Live clock (polled every second)         ║\n");
+    print("║    /click/*       - Click counter endpoints                  ║\n");
+    print("║    /tasks         - Task list (GET all)                      ║\n");
+    print("║    /tasks/add     - Add task (POST)                          ║\n");
+    print("║    /tasks/{id}/*  - Task operations (toggle, delete)         ║\n");
+    print("║    /area/click    - Click area counter                       ║\n");
+    print("╠══════════════════════════════════════════════════════════════╣\n");
+    print("║  htmx CDN:                                                   ║\n");
+    print("║    https://cdn.jsdelivr.net/npm/htmx.org@2.0.8               ║\n");
+    print("╚══════════════════════════════════════════════════════════════╝\n");
+    print("\nPress Ctrl+C to stop the server\n\n");
+    
+    try {
+        var application = new WebApplication(port);
+        
+        // Register compression
+        application.use_compression();
+        
+        // Register template as singleton
+        application.add_singleton<HtmxTemplate>();
+        
+        // Register CSS as FastResource
+        application.add_singleton_endpoint<FastResource>(new EndpointRoute("/styles.css"), () => {
+            try {
+                return new FastResource.from_string(HTMX_CSS)
+                    .with_content_type("text/css; charset=utf-8")
+                    .with_default_compressors();
+            } catch (Error e) {
+                error("Failed to create CSS resource: %s", e.message);
+            }
+        });
+        
+        // Main page
+        application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));
+        
+        // Clock endpoint
+        application.add_endpoint<ClockEndpoint>(new EndpointRoute("/clock"));
+        
+        // Click counter endpoints
+        application.add_endpoint<ClickIncrementEndpoint>(new EndpointRoute("/click/increment"));
+        application.add_endpoint<ClickDecrementEndpoint>(new EndpointRoute("/click/decrement"));
+        application.add_endpoint<ClickResetEndpoint>(new EndpointRoute("/click/reset"));
+        application.add_endpoint<ClickHistoryEndpoint>(new EndpointRoute("/click/history"));
+        application.add_endpoint<ClickTimeEndpoint>(new EndpointRoute("/click/time"));
+        
+        // Area click endpoint
+        application.add_endpoint<AreaClickEndpoint>(new EndpointRoute("/area/click"));
+        
+        // Task endpoints
+        application.add_endpoint<TaskListEndpoint>(new EndpointRoute("/tasks"));
+        application.add_endpoint<AddTaskEndpoint>(new EndpointRoute("/tasks/add"));
+        application.add_endpoint<ToggleTaskEndpoint>(new EndpointRoute("/tasks/{id}/toggle"));
+        application.add_endpoint<DeleteTaskEndpoint>(new EndpointRoute("/tasks/{id}/delete"));
+        
+        application.run();
+        
+    } catch (Error e) {
+        printerr("Error: %s\n", e.message);
+        Process.exit(1);
+    }
+}

+ 7 - 0
examples/meson.build

@@ -95,3 +95,10 @@ executable('document-builder-template',
     dependencies: [astralis_dep, invercargill_dep],
     install: false
 )
+
+# HTMX Example - demonstrates htmx integration with dynamic content swapping
+executable('htmx-example',
+    'HtmxExample.vala',
+    dependencies: [astralis_dep, invercargill_dep],
+    install: false
+)

+ 21 - 38
src/Markup/MarkupDocument.vala

@@ -1,4 +1,5 @@
 using Xml;
+using Html;
 using Invercargill;
 using Invercargill.DataStructures;
 
@@ -7,13 +8,13 @@ namespace Astralis {
     /// Represents an HTML document that can be loaded, manipulated, and rendered
     /// </summary>
     public class MarkupDocument : GLib.Object {
-        private Xml.Doc* doc;
+        private Html.Doc* doc;
 
         /// <summary>
         /// Creates a new empty HTML document
         /// </summary>
         public MarkupDocument() {
-            doc = new Xml.Doc();
+            doc = new Html.Doc();
             // Create basic HTML structure
             var html = doc->new_node(null, "html");
             doc->set_root_element(html);
@@ -50,33 +51,30 @@ namespace Astralis {
         }
         
         /// <summary>
-        /// Creates a MarkupDocument from an existing Xml.Doc pointer.
+        /// Creates a MarkupDocument from an existing Html.Doc pointer.
         /// The document takes ownership of the passed pointer and will free it on destruction.
         /// This is primarily used by MarkupTemplate to create instances from cached templates.
         /// </summary>
-        /// <param name="existing_doc">An existing Xml.Doc pointer that this document will own</param>
-        public MarkupDocument.from_doc(owned Xml.Doc* existing_doc) {
+        /// <param name="existing_doc">An existing Html.Doc pointer that this document will own</param>
+        public MarkupDocument.from_doc(owned Html.Doc* existing_doc) {
             doc = existing_doc;
         }
 
         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)(Html.ParserOption.RECOVER |
+                Html.ParserOption.NOERROR |
+                Html.ParserOption.NOWARNING |
+                Html.ParserOption.NOBLANKS |
+                Html.ParserOption.NONET);
             
-            int options = (int)(ParserOption.RECOVER |
-                ParserOption.NOERROR |
-                ParserOption.NOWARNING |
-                ParserOption.NOBLANKS |
-                ParserOption.NONET);
-            
-            doc = Parser.read_memory(html, html.length, null, null, options);
+            char[] buffer = html.to_utf8();
+            doc = Html.Doc.read_memory(buffer, buffer.length, "", 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);
+                char[] wrapped_buffer = wrapped.to_utf8();
+                doc = Html.Doc.read_memory(wrapped_buffer, wrapped_buffer.length, "", null, options);
             }
             
             if (doc == null) {
@@ -245,12 +243,13 @@ namespace Astralis {
         }
 
         /// <summary>
-        /// Converts the document to an HTML string
+        /// Converts the document to an HTML string using libxml's HTML serializer
         /// </summary>
         public string to_html() {
             string buffer;
-            doc->dump_memory(out buffer);
-            return strip_xml_declaration(buffer);
+            int len;
+            doc->dump_memory(out buffer, out len);
+            return buffer ?? "";
         }
 
         /// <summary>
@@ -260,23 +259,7 @@ namespace Astralis {
             string buffer;
             int len;
             doc->dump_memory_format(out buffer, out len, true);
-            return strip_xml_declaration(buffer);
-        }
-
-        private string strip_xml_declaration(string html) {
-            // Remove XML declaration if present (e.g., <?xml version="1.0"?>)
-            if (html.has_prefix("<?xml")) {
-                int end = html.index_of("?>");
-                if (end >= 0) {
-                    // Skip the declaration and any following whitespace/newline
-                    int start = end + 2;
-                    while (start < html.length && html[start].isspace()) {
-                        start++;
-                    }
-                    return html.substring(start);
-                }
-            }
-            return html;
+            return buffer ?? "";
         }
 
         /// <summary>
@@ -358,4 +341,4 @@ namespace Astralis {
             get { return doc; }
         }
     }
-}
+}

+ 53 - 25
src/Markup/MarkupNode.vala

@@ -1,4 +1,5 @@
 using Xml;
+using Html;
 using Invercargill;
 using Invercargill.DataStructures;
 
@@ -282,8 +283,16 @@ namespace Astralis {
         /// 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);
+            // 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 = "<div>" + html + "</div>";
+            char[] buffer = wrapped.to_utf8();
+            var temp_doc = Html.Doc.read_memory(buffer, buffer.length, "", null, options);
             if (temp_doc == null) {
                 return;
             }
@@ -319,8 +328,16 @@ namespace Astralis {
                     return;
                 }
                 
-                // Parse the HTML fragment
-                var temp_doc = Parser.parse_memory("<div>" + value + "</div>", value.length);
+                // 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 = "<div>" + value + "</div>";
+                char[] buffer = wrapped.to_utf8();
+                var temp_doc = Html.Doc.read_memory(buffer, buffer.length, "", null, options);
                 if (temp_doc == null) {
                     return;
                 }
@@ -340,14 +357,35 @@ namespace Astralis {
                 delete temp_doc;
             }
             owned get {
-                var buffer = new StringBuilder();
+                // 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 child_content = child->get_content();
-                    if (child_content != null) {
-                        buffer.append(child_content);
-                    }
+                    var copied = child->copy(1);
+                    wrapper->add_child(copied);
                 }
-                return buffer.str;
+                
+                // Serialize using HTML serializer
+                string buffer;
+                int len;
+                temp_doc.dump_memory(out buffer, out len);
+                
+                // Extract just the inner content (between <div> and </div>)
+                // The HTML serializer outputs proper HTML without XML declaration
+                string result = buffer ?? "";
+                
+                // Strip the wrapper div tags
+                if (result.has_prefix("<div>")) {
+                    result = result.substring(5);
+                }
+                if (result.has_suffix("</div>")) {
+                    result = result.substring(0, result.length - 6);
+                }
+                
+                return result;
             }
         }
 
@@ -356,22 +394,12 @@ namespace Astralis {
         /// </summary>
         public string outer_html {
             owned get {
-                // Create a temporary document to serialize this node
-                var temp_doc = new Xml.Doc();
+                // 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;
-                temp_doc.dump_memory(out buffer);
-                // Strip XML declaration
-                if (buffer.has_prefix("<?xml")) {
-                    int end = buffer.index_of("?>");
-                    if (end >= 0) {
-                        int start = end + 2;
-                        while (start < buffer.length && buffer[start].isspace()) {
-                            start++;
-                        }
-                        return buffer.substring(start);
-                    }
-                }
+                int len;
+                temp_doc.dump_memory(out buffer, out len);
                 return buffer ?? "";
             }
         }
@@ -561,4 +589,4 @@ namespace Astralis {
             }
         }
     }
-}
+}

+ 15 - 13
src/Markup/MarkupTemplate.vala

@@ -1,4 +1,5 @@
 using Xml;
+using Html;
 using Invercargill;
 
 namespace Astralis {
@@ -11,7 +12,7 @@ namespace Astralis {
     /// that can be safely modified without affecting the original.
     /// </summary>
     public abstract class MarkupTemplate : GLib.Object {
-        private Xml.Doc* cached_doc = null;
+        private Html.Doc* cached_doc = null;
         private bool loaded = false;
         private GLib.Mutex mutex = GLib.Mutex();
         
@@ -55,20 +56,20 @@ namespace Astralis {
                 
                 string html = get_markup();
                 
-                Parser.init();
+                int options = (int)(Html.ParserOption.RECOVER |
+                    Html.ParserOption.NOERROR |
+                    Html.ParserOption.NOWARNING |
+                    Html.ParserOption.NOBLANKS |
+                    Html.ParserOption.NONET);
                 
-                int options = (int)(ParserOption.RECOVER |
-                    ParserOption.NOERROR |
-                    ParserOption.NOWARNING |
-                    ParserOption.NOBLANKS |
-                    ParserOption.NONET);
-                
-                cached_doc = Parser.read_memory(html, html.length, null, null, options);
+                char[] buffer = html.to_utf8();
+                cached_doc = Html.Doc.read_memory(buffer, buffer.length, "", null, options);
                 
                 if (cached_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);
-                    cached_doc = Parser.read_memory(wrapped, wrapped.length, null, null, options);
+                    char[] wrapped_buffer = wrapped.to_utf8();
+                    cached_doc = Html.Doc.read_memory(wrapped_buffer, wrapped_buffer.length, "", null, options);
                 }
                 
                 if (cached_doc == null) {
@@ -92,13 +93,14 @@ namespace Astralis {
             ensure_loaded();
             
             // Create a deep copy of the cached document (1 = recursive/deep copy)
-            Xml.Doc* copy = cached_doc->copy(1);
+            // Note: copy() returns Xml.Doc* but Html.Doc is a subclass, so we cast
+            Xml.Doc* xml_copy = cached_doc->copy(1);
             
-            if (copy == null) {
+            if (xml_copy == null) {
                 throw new MarkupError.PARSE_ERROR("Failed to copy template document");
             }
             
-            return new MarkupDocument.from_doc(copy);
+            return new MarkupDocument.from_doc((Html.Doc*)xml_copy);
         }
         
         /// <summary>