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 tasks; public TaskManager() { tasks = new List(); // 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? found_link = null; unowned List? 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 markup { get { return """ htmx Example - Astralis

htmx + Astralis: High Power Tools for HTML

htmx Examples

This page demonstrates htmx integration with Astralis. Content updates happen via AJAX without full page reloads.

🕐 Live Clock

Auto-updates every second using hx-trigger="every 1s":

Loading...

👆 Click Counter

Click buttons to update the counter. Uses hx-post and hx-swap="innerHTML":

0
Last Click
--:--:--
History
No clicks yet

✅ Task List

A simple task manager with add, toggle, and delete operations:

🎯 Click Area

Click anywhere in the area below to record a click. Uses hx-trigger="click":

Click anywhere in this area!

Clicks: 0

How It Works

htmx extends HTML with attributes that enable AJAX directly in your markup:


Click to load
Loading clock...

Key htmx Attributes Used:

  • hx-get - Make a GET request to the specified URL
  • hx-post - Make a POST request to the specified URL
  • hx-trigger - When to trigger the request (click, load, every Ns)
  • hx-target - CSS selector for where to place the response
  • hx-swap - How to swap content (innerHTML, outerHTML, beforeend)

Built with Astralis + htmx | htmx docs

"""; }} } // 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(@"
  • "); sb.append(@"$(escape_html(task.text))"); sb.append(@"
    "); sb.append(@" "); sb.append(@""); sb.append(@"
    "); sb.append(@"
  • "); } return sb.str; } string escape_html(string text) { var result = text.replace("&", "&"); result = result.replace("<", "<"); result = result.replace(">", ">"); result = result.replace("\"", """); return result; } // Main page endpoint class HomePageEndpoint : Object, Endpoint { private HtmxTemplate template = Inversion.inject(); 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(); // Register CSS as FastResource application.add_singleton_endpoint(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(new EndpointRoute("/")); // Clock endpoint application.add_endpoint(new EndpointRoute("/clock")); // Click counter endpoints application.add_endpoint(new EndpointRoute("/click/increment")); application.add_endpoint(new EndpointRoute("/click/decrement")); application.add_endpoint(new EndpointRoute("/click/reset")); application.add_endpoint(new EndpointRoute("/click/history")); application.add_endpoint(new EndpointRoute("/click/time")); // Area click endpoint application.add_endpoint(new EndpointRoute("/area/click")); // Task endpoints application.add_endpoint(new EndpointRoute("/tasks")); application.add_endpoint(new EndpointRoute("/tasks/add")); application.add_endpoint(new EndpointRoute("/tasks/{id}/toggle")); application.add_endpoint(new EndpointRoute("/tasks/{id}/delete")); application.run(); } catch (Error e) { printerr("Error: %s\n", e.message); Process.exit(1); } }