Explorar o código

feat(spry): add HTMX integration and IoC composition support

- Add declarative HTMX attributes (spry-action, spry-target, spry-global)
- Add ComponentFactory for IoC-based component instantiation
- Add inject<T>() for dependency injection in components
- Add handle_action() method for HTMX request handling
- Add prepare() lifecycle method for async data population
- Add ComponentUriProvider for automatic action endpoint routing
- Add SpryModule for automatic component registration
- Make to_document() and to_result() async for proper yielding
- Update all examples to use IoC pattern with HTMX interactions
Billy Barrow hai 1 semana
pai
achega
bccd60b7f1

+ 148 - 92
examples/CounterComponent.vala

@@ -7,20 +7,21 @@ using Spry;
 /**
  * CounterComponent Example
  * 
- * Demonstrates using the Spry.Component class to build dynamic HTML pages
- * with outlets for component composition. Shows how to:
- *   - Define a Component subclass with embedded HTML markup
+ * Demonstrates using the Spry.Component class with IoC composition and HTMX.
+ * Shows how to:
+ *   - Define Component subclasses with embedded HTML markup
  *   - Use spry-outlet elements for dynamic content injection
- *   - Use add_outlet_child/set_outlet_child methods to populate outlets
- *   - Handle form POST to update state
- * 
- * This example mirrors the Astralis DocumentBuilderTemplate example but
- * uses the Component class abstraction for better code organization.
+ *   - Use inject<T>() for dependency injection
+ *   - Use ComponentFactory to create component instances
+ *   - Use spry-action and spry-target for declarative HTMX interactions
+ *   - Use handle_action() for action handling
  * 
  * Usage: counter-component [port]
  */
 
-// Application state - a simple counter
+/**
+ * AppState - Application state registered as singleton.
+ */
 class AppState : Object {
     public int counter { get; set; }
     public int total_changes { get; set; }
@@ -237,7 +238,7 @@ class InfoItemComponent : Component {
  */
 class InfoGridComponent : Component {
     
-    public void set_items(Enumerable<Component> items) {
+    public void set_items(Enumerable<Renderable> items) {
         set_outlet_children("items", items);
     }
     
@@ -251,43 +252,110 @@ class InfoGridComponent : Component {
 }
 
 /**
- * CounterDisplayComponent - Shows the counter value with buttons.
+ * CounterDisplayComponent - Shows the counter value with HTMX buttons.
+ * Uses declarative spry-action and spry-target attributes.
  */
 class CounterDisplayComponent : Component {
-    private InfoGridComponent _info_grid;
-    
-    public CounterDisplayComponent() {
-        _info_grid = new InfoGridComponent();
-    }
+    private ComponentFactory factory = inject<ComponentFactory>();
+    private AppState app_state = inject<AppState>();
     
-    public void set_info_items(Enumerable<Component> items) {
-        _info_grid.set_items(items);
-        set_outlet_child("info-grid", _info_grid);
+    public void set_info_items(Enumerable<Renderable> items) throws Error {
+        var info_grid = factory.create<InfoGridComponent>();
+        info_grid.set_items(items);
+        set_outlet_child("info-grid", info_grid);
     }
     
     public override string markup { get {
         return """
-        <div class="card">
+        <div class="card" sid="counter-card">
             <div class="counter-display">
                 <div class="counter-label">Current Value</div>
                 <div class="counter-value" sid="counter-value">0</div>
             </div>
             <div class="button-group">
-                <form method="POST" action="/decrement" style="display: inline;">
-                    <button type="submit" class="btn-danger">- Decrease</button>
-                </form>
-                <form method="POST" action="/reset" style="display: inline;">
-                    <button type="submit" class="btn-primary">Reset</button>
-                </form>
-                <form method="POST" action="/increment" style="display: inline;">
-                    <button type="submit" class="btn-success">+ Increase</button>
-                </form>
+                <button type="button" class="btn-danger" sid="decrement-btn" spry-action=":Decrement" spry-target="counter-card" hx-swap="outerHTML">- Decrease</button>
+                <button type="button" class="btn-primary" sid="reset-btn" spry-action=":Reset" spry-target="counter-card" hx-swap="outerHTML">Reset</button>
+                <button type="button" class="btn-success" sid="increment-btn" spry-action=":Increment" spry-target="counter-card" hx-swap="outerHTML">+ Increase</button>
             </div>
             <spry-outlet sid="info-grid"/>
         </div>
         """;
     }}
     
+    public async override void handle_action(string action) throws Error {
+        // Update state based on action
+        switch (action) {
+            case "Increment":
+                app_state.increment();
+                break;
+            case "Decrement":
+                app_state.decrement();
+                break;
+            case "Reset":
+                app_state.reset();
+                break;
+        }
+        
+        // Update counter value
+        var counter_el = this["counter-value"];
+        counter_el.text_content = app_state.counter.to_string();
+        
+        // Update status class
+        counter_el.remove_class("status-positive");
+        counter_el.remove_class("status-negative");
+        counter_el.remove_class("status-zero");
+        
+        if (app_state.counter > 0) {
+            counter_el.add_class("status-positive");
+        } else if (app_state.counter < 0) {
+            counter_el.add_class("status-negative");
+        } else {
+            counter_el.add_class("status-zero");
+        }
+        
+        // Build info grid
+        var info_grid = factory.create<InfoGridComponent>();
+        var items = new Series<Renderable>();
+        
+        // Determine status
+        string status_text;
+        string status_class;
+        if (app_state.counter > 0) {
+            status_text = "Positive";
+            status_class = "status-positive";
+        } else if (app_state.counter < 0) {
+            status_text = "Negative";
+            status_class = "status-negative";
+        } else {
+            status_text = "Zero";
+            status_class = "status-zero";
+        }
+        
+        var status_item = factory.create<InfoItemComponent>();
+        status_item.label = "Status";
+        status_item.info_value = status_text;
+        status_item.status_class = status_class;
+        items.add(status_item);
+        
+        var action_item = factory.create<InfoItemComponent>();
+        action_item.label = "Last Action";
+        action_item.info_value = app_state.last_action;
+        items.add(action_item);
+        
+        var time_item = factory.create<InfoItemComponent>();
+        time_item.label = "Last Update";
+        time_item.info_value = app_state.last_update.format("%H:%M:%S");
+        items.add(time_item);
+        
+        var changes_item = factory.create<InfoItemComponent>();
+        changes_item.label = "Total Changes";
+        changes_item.info_value = app_state.total_changes.to_string();
+        items.add(changes_item);
+        
+        info_grid.set_items(items);
+        set_outlet_child("info-grid", info_grid);
+    }
+    
     public int counter { set {
         var counter_el = this["counter-value"];
         counter_el.text_content = value.to_string();
@@ -308,11 +376,11 @@ class CounterDisplayComponent : Component {
 }
 
 /**
- * CounterPageComponent - The main page component.
+ * CounterPageComponent - The main page structure.
  */
 class CounterPageComponent : Component {
     
-    public void set_counter_section(Component component) {
+    public void set_counter_section(Renderable component) {
         set_outlet_child("counter-section", component);
     }
     
@@ -325,27 +393,30 @@ class CounterPageComponent : Component {
             <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
             <title>Component Counter Example</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="card">
                 <h1>Component Counter Example</h1>
-                <p>This page demonstrates using Spry.Component with outlets for dynamic content.</p>
+                <p>This page demonstrates using Spry.Component with IoC composition and HTMX.</p>
             </div>
             <spry-outlet sid="counter-section"/>
             <div class="card">
                 <h2>How It Works</h2>
-                <p>The page uses Component with spry-outlet elements for dynamic content:</p>
-                <pre>component.set_outlet_child("content", child);</pre>
+                <p>The counter uses declarative HTMX attributes for dynamic updates:</p>
+                <pre>&lt;button spry-action=":Increment" spry-target="counter-card" hx-swap="outerHTML"&gt;+ Increase&lt;/button&gt;</pre>
                 <h3>Component Features Used:</h3>
                 <ul class="feature-list">
                     <li><code>spry-outlet</code> - Placeholder for child components</li>
-                    <li><code>set_outlet_child()</code> - Insert single component</li>
-                    <li><code>to_result()</code> - Render as HttpResult</li>
+                    <li><code>spry-action=":Action"</code> - Declarative HTMX action</li>
+                    <li><code>spry-target="sid"</code> - Target element by sid</li>
+                    <li><code>handle_action(action)</code> - Handle HTMX requests</li>
+                    <li><code>inject<ComponentFactory>()</code> - Factory for creating components</li>
                 </ul>
             </div>
             <div class="card">
                 <p style="text-align: center; color: #666; margin: 0;">
-                    Built with <code>Spry.Component</code> |
+                    Built with <code>Spry.Component</code> + HTMX |
                     <a href="/raw">View Raw HTML</a>
                 </p>
             </div>
@@ -369,11 +440,11 @@ class NoticeComponent : Component {
     }}
 }
 
-// Global app state
-AppState app_state;
-
 // Home page endpoint - builds the component tree
 class HomePageEndpoint : Object, Endpoint {
+    private AppState app_state = inject<AppState>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
     public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
         // Determine status
         string status_text;
@@ -390,102 +461,74 @@ class HomePageEndpoint : Object, Endpoint {
         }
         
         // Create info items
-        var items = new Series<Component>();
+        var items = new Series<Renderable>();
         
-        var status_item = new InfoItemComponent();
+        var status_item = factory.create<InfoItemComponent>();
         status_item.label = "Status";
         status_item.info_value = status_text;
         status_item.status_class = status_class;
         items.add(status_item);
         
-        var action_item = new InfoItemComponent();
+        var action_item = factory.create<InfoItemComponent>();
         action_item.label = "Last Action";
         action_item.info_value = app_state.last_action;
         items.add(action_item);
         
-        var time_item = new InfoItemComponent();
+        var time_item = factory.create<InfoItemComponent>();
         time_item.label = "Last Update";
         time_item.info_value = app_state.last_update.format("%H:%M:%S");
         items.add(time_item);
         
-        var changes_item = new InfoItemComponent();
+        var changes_item = factory.create<InfoItemComponent>();
         changes_item.label = "Total Changes";
         changes_item.info_value = app_state.total_changes.to_string();
         items.add(changes_item);
         
-        // Create the counter display component and set its info items
-        var counter_display = new CounterDisplayComponent();
+        // Create counter display
+        var counter_display = factory.create<CounterDisplayComponent>();
         counter_display.counter = app_state.counter;
         counter_display.set_info_items(items);
         
-        // Create the main page component and set the counter section
-        var page = new CounterPageComponent();
+        // Create page and set counter section
+        var page = factory.create<CounterPageComponent>();
         page.set_counter_section(counter_display);
         
         // to_result() handles all outlet replacement
-        return page.to_result();
-    }
-}
-
-// Increment endpoint
-class IncrementEndpoint : Object, Endpoint {
-    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        app_state.increment();
-        return new HttpStringResult("", (StatusCode)302)
-            .set_header("Location", "/");
-    }
-}
-
-// Decrement endpoint
-class DecrementEndpoint : Object, Endpoint {
-    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        app_state.decrement();
-        return new HttpStringResult("", (StatusCode)302)
-            .set_header("Location", "/");
-    }
-}
-
-// Reset endpoint
-class ResetEndpoint : Object, Endpoint {
-    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        app_state.reset();
-        return new HttpStringResult("", (StatusCode)302)
-            .set_header("Location", "/");
+        return yield page.to_result();
     }
 }
 
 // Raw HTML endpoint - shows the unmodified template
 class RawHtmlEndpoint : Object, Endpoint {
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
     public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        // Create a raw page without modifications
-        var page = new CounterPageComponent();
+        var page = factory.create<CounterPageComponent>();
+        var notice = factory.create<NoticeComponent>();
         
         // Add a notice component
-        page.set_counter_section(new NoticeComponent());
+        page.set_counter_section(notice);
         
-        return page.to_result();
+        return yield page.to_result();
     }
 }
 
 void main(string[] args) {
     int port = args.length > 1 ? int.parse(args[1]) : 8080;
     
-    // Initialize app state
-    app_state = new AppState();
-    
     print("═══════════════════════════════════════════════════════════════\n");
-    print("            Spry CounterComponent Example\n");
+    print("            Spry CounterComponent Example (IoC + HTMX)\n");
     print("═══════════════════════════════════════════════════════════════\n");
     print("  Port: %d\n", port);
     print("═══════════════════════════════════════════════════════════════\n");
     print("  Endpoints:\n");
-    print("    /           - Counter page (Component-based)\n");
+    print("    /           - Counter page (Component-based with HTMX)\n");
     print("    /styles.css - CSS stylesheet (FastResource)\n");
-    print("    /increment  - Increase counter (POST)\n");
-    print("    /decrement  - Decrease counter (POST)\n");
-    print("    /reset      - Reset counter (POST)\n");
     print("    /raw        - Raw template (no modifications)\n");
     print("═══════════════════════════════════════════════════════════════\n");
+    print("  HTMX Actions (via SpryModule):\n");
+    print("    Increment, Decrement, Reset - Dynamic updates\n");
+    print("═══════════════════════════════════════════════════════════════\n");
     print("\nPress Ctrl+C to stop the server\n\n");
     
     try {
@@ -494,6 +537,22 @@ void main(string[] args) {
         // Register compression components
         application.use_compression();
         
+        // Add Spry module for automatic HTMX endpoint registration
+        application.add_module<SpryModule>();
+        
+        // Register application state as singleton
+        application.add_singleton<AppState>();
+        
+        // Register ComponentFactory as scoped
+        application.add_scoped<ComponentFactory>();
+        
+        // Register components as transient (created via factory)
+        application.add_transient<InfoItemComponent>();
+        application.add_transient<InfoGridComponent>();
+        application.add_transient<CounterDisplayComponent>();
+        application.add_transient<CounterPageComponent>();
+        application.add_transient<NoticeComponent>();
+        
         // Register CSS as a FastResource
         application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles.css"), () => {
             try {
@@ -507,9 +566,6 @@ void main(string[] args) {
         
         // Register endpoints
         application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));
-        application.add_endpoint<IncrementEndpoint>(new EndpointRoute("/increment"));
-        application.add_endpoint<DecrementEndpoint>(new EndpointRoute("/decrement"));
-        application.add_endpoint<ResetEndpoint>(new EndpointRoute("/reset"));
         application.add_endpoint<RawHtmlEndpoint>(new EndpointRoute("/raw"));
         
         application.run();

+ 45 - 23
examples/SimpleExample.vala

@@ -10,6 +10,9 @@ class TestComponent : Component {
         return """
         <!DOCTYPE html>
         <html>
+        <head>
+        <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
+        </head>
         <body>
         <h1>Hello, World!</h1>
         <spry-outlet sid="content" />
@@ -18,8 +21,8 @@ class TestComponent : Component {
         """;
     }}
 
-    public void set_content(Component component) {
-        set_outlet_child("content", component);
+    public void add_content(Renderable content) {
+        add_outlet_child("content", content);
     }
 
 }
@@ -37,41 +40,52 @@ class ContentComponent : Component {
 
 }
 
-class UserContentComponent : Component {
+class MessageFormComponent : Component {
+
+    private ComponentUriProvider component_uri_provider = inject<ComponentUriProvider>();
+
     public override string markup { get {
         return """
-        <p>You said: <span sid="message"></span></p>
+        <form sid="form" spry-action="UserContentComponent:SendMessage" spry-target="result">
+            <input type="text" name="message" placeholder="Your message here"/>
+            <input type="submit"/>
+        </form>
+        <div sid="result">
+        <em>Message will show here</em>
+        </div>
         """;
     }}
 
-    public string message { set {
-        this["message"].text_content = value;
-    }}
 
 }
 
-class HomePageEndpoint : Object, Endpoint {
+class UserContentComponent : Component {
+    public override string markup { get {
+        return """
+        <p>You said: <strong sid="message"></strong> <em>(via <span sid="action"></span>)</em></p>
+        """;
+    }}
 
-    public async Astralis.HttpResult handle_request(Astralis.HttpContext http_context, Astralis.RouteContext route_context) throws Error {
-        var component = new TestComponent();
-        component.set_content(new ContentComponent());
-        return component.to_result();
-    }
+    private HttpContext http_context = inject<HttpContext>();
 
+    public async override void handle_action(string action) throws Error {
+        this["message"].text_content = http_context.request.query_params.get_any_or_default("message") ?? "No message provided!";
+        this["action"].text_content = action;
+    }
 
 }
 
-class MessageEndpoint : Object, Endpoint {
+class HomePageEndpoint : Object, Endpoint {
+
+    private TestComponent test_component = inject<TestComponent>();
+    private ContentComponent content_component = inject<ContentComponent>();
+    private MessageFormComponent message_form = inject<MessageFormComponent>();
 
     public async Astralis.HttpResult handle_request(Astralis.HttpContext http_context, Astralis.RouteContext route_context) throws Error {
-        var component = new TestComponent();
-        var content = new UserContentComponent();
-        content.message = http_context.request.query_params.get_any_or_default("message") ?? "No message provided!";
-        component.set_content(content);
-        return component.to_result();
+        test_component.add_content(content_component);
+        test_component.add_content(message_form);
+        return yield test_component.to_result();
     }
-
-
 }
 
 
@@ -85,10 +99,18 @@ void main(string[] args) {
         // Register compression components
         application.use_compression();
         
+        // Add Spry
+        application.add_module<SpryModule>();
+
+        // Register components
+        application.add_transient<TestComponent>();
+        application.add_transient<ContentComponent>();
+        application.add_transient<MessageFormComponent>();
+        application.add_transient<UserContentComponent>();
+
         // Register endpoints
         application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));
-        application.add_endpoint<MessageEndpoint>(new EndpointRoute("/message"));
-        
+
         application.run();
         
     } catch (Error e) {

+ 174 - 145
examples/TodoComponent.vala

@@ -7,19 +7,20 @@ using Spry;
 /**
  * TodoComponent Example
  * 
- * Demonstrates using the Spry.Component class to build a todo list
- * application with component composition. Shows how to:
+ * Demonstrates using the Spry.Component class with IoC composition.
+ * Shows how to:
  *   - Build a complete CRUD interface with Components
- *   - Use set_outlet_child/set_outlet_children for component composition
- *   - Handle form submissions
- * 
- * This example mirrors the Astralis DocumentBuilder example but
- * uses the Component class abstraction for better code organization.
+ *   - Use inject<T>() for dependency injection
+ *   - Use ComponentFactory to create component instances
+ *   - Use spry-action and spry-target for declarative HTMX interactions
+ *   - Use handle_action() for action handling
  * 
  * Usage: todo-component [port]
  */
 
-// Simple task model for our todo list
+/**
+ * TodoItem - Simple task model for our todo list.
+ */
 class TodoItem : Object {
     public int id { get; set; }
     public string title { get; set; }
@@ -32,7 +33,9 @@ class TodoItem : Object {
     }
 }
 
-// In-memory todo store
+/**
+ * TodoStore - In-memory todo store registered as singleton.
+ */
 class TodoStore : Object {
     private Series<TodoItem> items = new Series<TodoItem>();
     private int next_id = 1;
@@ -77,9 +80,6 @@ class TodoStore : Object {
     }
 }
 
-// Main application with todo store
-TodoStore todo_store;
-
 /**
  * EmptyListComponent - Shown when no items exist.
  */
@@ -94,67 +94,135 @@ class EmptyListComponent : Component {
 }
 
 /**
- * TodoItemComponent - A single todo item.
+ * TodoItemComponent - A single todo item with HTMX actions.
  */
 class TodoItemComponent : Component {
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    private HttpContext http_context = inject<HttpContext>();
+    private HeaderComponent header = inject<HeaderComponent>();
+    
+    private int _item_id;
+    public int item_id {
+        set {
+            _item_id = value;
+        }
+        get {
+            return _item_id;
+        }
+    }
+    
     public override string markup { get {
         return """
-        <div class="todo-item">
-            <form method="POST" action="/toggle" style="display: inline;">
-                <input type="hidden" name="id" sid="toggle-id"/>
-                <button type="submit" class="btn-toggle" sid="toggle-btn"></button>
-            </form>
+        <div class="todo-item" sid="item">
+            <button type="button" class="btn-toggle" sid="toggle-btn" spry-action=":Toggle" spry-target="item" hx-swap="outerHTML"></button>
             <span class="title" sid="title"></span>
-            <form method="POST" action="/delete" style="display: inline;">
-                <input type="hidden" name="id" sid="delete-id"/>
-                <button type="submit" class="btn-delete">Delete</button>
-            </form>
+            <button type="button" class="btn-delete" sid="delete-btn" spry-action=":Delete" spry-target="item" hx-swap="delete">Delete</button>
         </div>
         """;
     }}
     
-    public int item_id { set {
-        this["toggle-id"].set_attribute("value", value.to_string());
-        this["delete-id"].set_attribute("value", value.to_string());
-    }}
-    
-    public string title { set {
-        this["title"].text_content = value;
-    }}
+    // Called before serialization to prepare template with data from store
+    public override async void prepare() throws Error {
+        var item = todo_store.all().first_or_default(i => i.id == _item_id);
+        if (item == null) return;
+        
+        this["title"].text_content = item.title;
+        this["toggle-btn"].text_content = item.completed ? "Undo" : "Done";
+        if (item.completed) {
+            this["item"].add_class("completed");
+        }
+        
+        // Set hx-vals on parent div - inherited by child buttons
+        this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");
+    }
     
-    public bool completed { set {
-        this["toggle-btn"].text_content = value ? "Undo" : "Done";
-    }}
+    public async override void handle_action(string action) throws Error {
+        // Get the item id from query parameters (passed via hx-vals)
+        var id_str = http_context.request.query_params.get_any_or_default("id");
+        if (id_str == null) return;
+        
+        var id = int.parse(id_str);
+        _item_id = id; // Set for prepare()
+        
+        switch (action) {
+            case "Toggle":
+                todo_store.toggle(id);
+                // prepare() will be called automatically before serialization
+                // Add header globals for OOB swap (header's prepare() fetches stats)
+                add_globals_from(header);
+                break;
+            case "Delete":
+                todo_store.remove(id);
+                // With hx-swap="delete", we still need to return the header update
+                add_globals_from(header);
+                return;
+        }
+    }
 }
 
 /**
  * TodoListComponent - Container for todo items.
  */
 class TodoListComponent : Component {
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    private HttpContext http_context = inject<HttpContext>();
+    private HeaderComponent header = inject<HeaderComponent>();
     
-    public void set_items(Enumerable<Component> items) {
+    public void set_items(Enumerable<Renderable> items) {
         set_outlet_children("items", items);
     }
     
     public override string markup { get {
         return """
-        <div class="card">
+        <div class="card" id="todo-list" sid="todo-list">
             <h2>Todo List</h2>
             <spry-outlet sid="items"/>
         </div>
         """;
     }}
+    
+    public async override void handle_action(string action) throws Error {
+        if (action == "Add") {
+            var title = http_context.request.query_params.get_any_or_default("title");
+            
+            if (title != null && title.strip() != "") {
+                todo_store.add(title.strip());
+            }
+        }
+        
+        // Rebuild the list - only need to set item_id, prepare() handles the rest
+        var count = todo_store.count();
+        if (count == 0) {
+            set_outlet_children("items", Iterate.single<Renderable>(factory.create<EmptyListComponent>()));
+        } else {
+            var items = new Series<Renderable>();
+            todo_store.all().iterate((item) => {
+                var component = factory.create<TodoItemComponent>();
+                component.item_id = item.id;
+                items.add(component);
+            });
+            set_outlet_children("items", items);
+        }
+        
+        // Add header globals for OOB swap (header's prepare() fetches stats)
+        add_globals_from(header);
+    }
 }
 
 /**
  * HeaderComponent - Page header with stats.
+ * Uses prepare() to fetch data from store automatically.
  */
 class HeaderComponent : Component {
+    private TodoStore todo_store = inject<TodoStore>();
+    
     public override string markup { get {
         return """
-        <div class="card">
+        <div class="card" id="header" spry-global="header">
             <h1>Todo Component Example</h1>
-            <p>This page demonstrates <code>Spry.Component</code> for building dynamic pages.</p>
+            <p>This page demonstrates <code>Spry.Component</code> with IoC composition.</p>
             <div class="stats">
                 <div class="stat">
                     <div class="stat-value" sid="total"></div>
@@ -169,25 +237,27 @@ class HeaderComponent : Component {
         """;
     }}
     
-    public int total_tasks { set {
-        this["total"].text_content = value.to_string();
-    }}
-    
-    public int completed_tasks { set {
-        this["completed"].text_content = value.to_string();
-    }}
+    // Called before serialization to populate stats from store
+    public override async void prepare() throws Error {
+        this["total"].text_content = todo_store.count().to_string();
+        this["completed"].text_content = todo_store.completed_count().to_string();
+    }
 }
 
 /**
- * AddFormComponent - Form to add new todos.
+ * AddFormComponent - Form to add new todos with HTMX.
  */
 class AddFormComponent : Component {
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    private HttpContext http_context = inject<HttpContext>();
+    
     public override string markup { get {
         return """
         <div class="card">
             <h2>Add New Task</h2>
-            <form method="POST" action="/add" style="display: flex; gap: 10px;">
-                <input type="text" name="title" placeholder="Enter a new task..." required="required"/>
+            <form sid="form" spry-action="TodoListComponent:Add" hx-target="#todo-list" hx-swap="outerHTML" style="display: flex; gap: 10px;">
+                <input type="text" name="title" placeholder="Enter a new task..." required="required" sid="title-input"/>
                 <button type="submit" class="btn-primary">Add Task</button>
             </form>
         </div>
@@ -209,15 +279,23 @@ class FeaturesComponent : Component {
             </div>
             <div class="feature">
                 <strong>Using Outlets:</strong>
-                <code><spry-outlet sid="content"/></code>
+                <code>&lt;spry-outlet sid="content"/&gt;</code>
+            </div>
+            <div class="feature">
+                <strong>Preparing Templates:</strong>
+                <code>public override void prepare() { this["title"].text_content = "..."; }</code>
+            </div>
+            <div class="feature">
+                <strong>Declarative HTMX Actions:</strong>
+                <code>&lt;button spry-action=":Toggle" hx-vals='{"id":1}'&gt;Toggle&lt;/button&gt;</code>
             </div>
             <div class="feature">
-                <strong>Setting Outlet Content:</strong>
-                <code>set_outlet_child("content", child);</code>
+                <strong>IoC Injection:</strong>
+                <code>private ComponentFactory factory = inject<ComponentFactory>();</code>
             </div>
             <div class="feature">
-                <strong>Returning Results:</strong>
-                <code>return component.to_result();</code>
+                <strong>Creating Components:</strong>
+                <code>var child = factory.create&lt;MyComponent&gt;();</code>
             </div>
         </div>
         """;
@@ -244,23 +322,23 @@ class FooterComponent : Component {
  */
 class PageLayoutComponent : Component {
     
-    public void set_header(Component component) {
+    public void set_header(Renderable component) {
         set_outlet_child("header", component);
     }
     
-    public void set_todo_list(Component component) {
+    public void set_todo_list(Renderable component) {
         set_outlet_child("todo-list", component);
     }
     
-    public void set_add_form(Component component) {
+    public void set_add_form(Renderable component) {
         set_outlet_child("add-form", component);
     }
     
-    public void set_features(Component component) {
+    public void set_features(Renderable component) {
         set_outlet_child("features", component);
     }
     
-    public void set_footer(Component component) {
+    public void set_footer(Renderable component) {
         set_outlet_child("footer", component);
     }
     
@@ -272,6 +350,7 @@ class PageLayoutComponent : Component {
             <meta charset="UTF-8"/>
             <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
             <title>Todo Component Example</title>
+            <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
             <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); }
@@ -281,7 +360,6 @@ class PageLayoutComponent : Component {
                 .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; }
@@ -315,29 +393,27 @@ class PageLayoutComponent : Component {
 
 // Home page endpoint - builds the component tree
 class HomePageEndpoint : Object, Endpoint {
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
     public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        // Create the page layout
-        var page = new PageLayoutComponent();
+        // Create page layout
+        var page = factory.create<PageLayoutComponent>();
         
-        // Create header with stats
-        var header = new HeaderComponent();
-        header.total_tasks = todo_store.count();
-        header.completed_tasks = todo_store.completed_count();
-        page.set_header(header);
+        // Create header - prepare() fetches stats from store automatically
+        page.set_header(factory.create<HeaderComponent>());
         
-        // Create todo list
-        var todo_list = new TodoListComponent();
+        // Create todo list - only need to set item_id, prepare() handles the rest
+        var todo_list = factory.create<TodoListComponent>();
         
         var count = todo_store.count();
         if (count == 0) {
-            todo_list.set_items(Iterate.single<Component>(new EmptyListComponent()));
+            todo_list.set_items(Iterate.single<Renderable>(factory.create<EmptyListComponent>()));
         } else {
-            var items = new Series<Component>();
+            var items = new Series<Renderable>();
             todo_store.all().iterate((item) => {
-                var component = new TodoItemComponent();
+                var component = factory.create<TodoItemComponent>();
                 component.item_id = item.id;
-                component.title = item.title;
-                component.completed = item.completed;
                 items.add(component);
             });
             todo_list.set_items(items);
@@ -345,80 +421,23 @@ class HomePageEndpoint : Object, Endpoint {
         page.set_todo_list(todo_list);
         
         // Add form
-        page.set_add_form(new AddFormComponent());
+        page.set_add_form(factory.create<AddFormComponent>());
         
         // Features
-        page.set_features(new FeaturesComponent());
+        page.set_features(factory.create<FeaturesComponent>());
         
         // Footer
-        page.set_footer(new FooterComponent());
+        page.set_footer(factory.create<FooterComponent>());
         
         // to_result() handles all outlet replacement automatically
-        return page.to_result();
-    }
-}
-
-// 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", "/");
+        return yield page.to_result();
     }
 }
 
 // API endpoint that returns JSON representation of todos
 class TodoJsonEndpoint : Object, Endpoint {
+    private TodoStore todo_store = inject<TodoStore>();
+    
     public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
         var json_parts = new Series<string>();
         json_parts.add("{\"todos\": [");
@@ -445,21 +464,18 @@ class TodoJsonEndpoint : Object, Endpoint {
 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("             Spry TodoComponent Example\n");
+    print("             Spry TodoComponent Example (IoC)\n");
     print("═══════════════════════════════════════════════════════════════\n");
     print("  Port: %d\n", port);
     print("═══════════════════════════════════════════════════════════════\n");
     print("  Endpoints:\n");
     print("    /           - Todo list (Component-based)\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("═══════════════════════════════════════════════════════════════\n");
+    print("  HTMX Actions (via SpryModule):\n");
+    print("    Add, Toggle, Delete - Dynamic updates\n");
+    print("═══════════════════════════════════════════════════════════════\n");
     print("\nPress Ctrl+C to stop the server\n\n");
     
     try {
@@ -468,11 +484,24 @@ void main(string[] args) {
         // Register compression components (optional, for better performance)
         application.use_compression();
         
+        // Add Spry module for automatic HTMX endpoint registration
+        application.add_module<SpryModule>();
+        
+        // Register the todo store as singleton
+        application.add_singleton<TodoStore>();
+        
+        // Register components as transient (created via factory)
+        application.add_transient<EmptyListComponent>();
+        application.add_transient<TodoItemComponent>();
+        application.add_transient<TodoListComponent>();
+        application.add_transient<HeaderComponent>();
+        application.add_transient<AddFormComponent>();
+        application.add_transient<FeaturesComponent>();
+        application.add_transient<FooterComponent>();
+        application.add_transient<PageLayoutComponent>();
+        
         // Register endpoints
         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.run();

+ 278 - 0
important-details.md

@@ -0,0 +1,278 @@
+# Spry Framework - Important Details
+
+## Component Basics
+
+### Template DOM is NOT Persisted
+- The template is fresh on every request
+- Any state set via setters (like `title`, `completed`) is NOT available in `handle_action()`
+- Always use `prepare()` to fetch data from store and set up the template
+
+### The `prepare()` Method
+- Called automatically before serialization
+- Use this to fetch data from stores and populate the template
+- Centralizes data fetching logic in one place
+
+```vala
+public override void prepare() throws Error {
+    var item = store.get_by_id(_item_id);
+    if (item == null) return;
+    
+    this["title"].text_content = item.title;
+    this["button"].text_content = item.completed ? "Undo" : "Done";
+}
+```
+
+### The `handle_action()` Method
+- Called when HTMX requests trigger an action
+- Modify state in stores, then let `prepare()` handle template updates
+- For delete with `hx-swap="delete"`, just return (no content needed)
+
+```vala
+public async override void handle_action(string action) throws Error {
+    var id = get_id_from_query_params();
+    _item_id = id; // Set for prepare()
+    
+    switch (action) {
+        case "Toggle":
+            store.toggle(id);
+            break; // prepare() will be called automatically
+        case "Delete":
+            store.remove(id);
+            return; // HTMX removes element via hx-swap="delete"
+    }
+}
+```
+
+## HTMX Integration
+
+### Declarative Attributes
+
+#### `spry-action` - Declare HTMX Actions
+- `spry-action=":ActionName"` - Action on same component (e.g., `:Toggle`)
+- `spry-action="ComponentName:ActionName"` - Action on different component (cross-component)
+
+#### `spry-target` - Scoped Targeting (within same component)
+- `spry-target="sid"` - Targets element by its `sid` attribute
+- Only works within the SAME component
+- Automatically generates proper `hx-target` with unique IDs
+
+#### `hx-target` - Global Targeting (cross-component)
+- Use `hx-target="#id"` to target elements anywhere on the page
+- Requires the target element to have an `id` attribute (not just `sid`)
+
+#### `hx-swap` - Swap Strategies
+- `hx-swap="outerHTML"` - Replace entire target element (prevents nesting)
+- `hx-swap="delete"` - Remove target element (no response content needed)
+
+### Out-of-Band Swaps with `spry-global` and `add_globals_from()`
+
+Update multiple elements on the page with a single response using `spry-global`:
+
+```vala
+// 1. Add spry-global to elements that may need OOB updates
+// 2. Use prepare() to fetch data from store - no manual property setting needed!
+
+class HeaderComponent : Component {
+    private TodoStore todo_store = inject<TodoStore>();
+    
+    public override string markup { get {
+        return """
+        <div class="card" id="header" spry-global="header">
+            <h1>Todo App</h1>
+            <div sid="total"></div>
+        </div>
+        """;
+    }}
+    
+    // prepare() fetches data from store automatically
+    public override async void prepare() throws Error {
+        this["total"].text_content = todo_store.count().to_string();
+    }
+}
+
+// 3. Inject the component and pass it to add_globals_from()
+
+class ItemComponent : Component {
+    private HeaderComponent header = inject<HeaderComponent>();
+    
+    public override string markup { get {
+        return """
+        <div sid="item">...</div>
+        """;
+    }}
+    
+    public async override void handle_action(string action) throws Error {
+        store.toggle(id);
+        // Add header globals for OOB swap (header's prepare() fetches stats)
+        add_globals_from(header);
+    }
+}
+```
+
+The `spry-global="key"` attribute:
+- Automatically adds `hx-swap-oob="true"` when the element appears in a response
+- HTMX finds the existing element on the page and swaps it in place
+- Use `add_globals_from(component)` to append spry-global elements from another component
+- The component's `prepare()` method is called automatically to populate data
+
+### Passing Data with `hx-vals`
+
+Since template DOM is not persisted, use `hx-vals` to pass data:
+
+```vala
+// In prepare() - set on parent element, inherited by children
+this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");
+
+// In handle_action()
+var id_str = http_context.request.query_params.get_any_or_default("id");
+var id = int.parse(id_str);
+```
+
+**Note**: `hx-vals` is inherited by child elements, so set it on the parent div rather than individual buttons.
+
+## IoC Composition
+
+### Registration Patterns
+
+```vala
+// State as singleton
+application.add_singleton<AppState>();
+
+// Factory as scoped
+application.add_scoped<ComponentFactory>();
+
+// Components as transient
+application.add_transient<MyComponent>();
+```
+
+### Dependency Injection
+
+Use `inject<T>()` in field initializers:
+
+```vala
+class MyComponent : Component {
+    private TodoStore store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    private HttpContext http_context = inject<HttpContext>();
+}
+```
+
+### Creating Components
+
+Use `ComponentFactory.create<T>()` (not `inject<T>()` which only works in field initializers):
+
+```vala
+var child = factory.create<ChildComponent>();
+child.item_id = item.id;
+items.add(child);
+```
+
+## Common Patterns
+
+### List with Items Pattern
+
+```vala
+// Parent component
+class ListComponent : Component {
+    public void set_items(Enumerable<Renderable> items) {
+        set_outlet_children("items", items);
+    }
+    
+    public override string markup { get {
+        return """
+        <div id="my-list" sid="my-list">
+            <spry-outlet sid="items"/>
+        </div>
+        """;
+    }}
+}
+
+// Item component with prepare()
+class ItemComponent : Component {
+    private TodoStore store = inject<TodoStore>();
+    private HttpContext http_context = inject<HttpContext>();
+    private HeaderComponent header = inject<HeaderComponent>();
+    
+    private int _item_id;
+    public int item_id { set { _item_id = value; } }
+    
+    public override string markup { get {
+        return """
+        <div class="item" sid="item">
+            <span sid="title"></span>
+            <button sid="toggle-btn" spry-action=":Toggle" spry-target="item" hx-swap="outerHTML"></button>
+        </div>
+        """;
+    }}
+    
+    public override void prepare() throws Error {
+        var item = store.get_by_id(_item_id);
+        this["title"].text_content = item.title;
+        // hx-vals on parent is inherited by children
+        this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        var id = int.parse(http_context.request.query_params.get_any_or_default("id"));
+        _item_id = id;
+        
+        if (action == "Toggle") {
+            store.toggle(id);
+            // Add header globals for OOB swap (header's prepare() fetches stats)
+            add_globals_from(header);
+        }
+    }
+}
+
+// Creating the list
+var items = new Series<Renderable>();
+store.all().iterate((item) => {
+    var component = factory.create<ItemComponent>();
+    component.item_id = item.id; // Only set ID, prepare() handles rest
+    items.add(component);
+});
+list.set_items(items);
+```
+
+### Cross-Component Action Pattern
+
+```vala
+// Form in one component triggers action on another
+<form spry-action="ListComponent:Add" hx-target="#my-list" hx-swap="outerHTML">
+    <input name="title"/>
+    <button type="submit">Add</button>
+</form>
+
+// ListComponent handles the Add action
+class ListComponent : Component {
+    public async override void handle_action(string action) throws Error {
+        if (action == "Add") {
+            var title = http_context.request.query_params.get_any_or_default("title");
+            store.add(title);
+        }
+        // Rebuild list...
+    }
+}
+```
+
+## HTML Escaping
+
+`<pre>` and `<code>` tags do NOT escape their contents. Manually escape:
+
+```vala
+// Wrong
+<pre><button spry-action=":Toggle">Click</button></pre>
+
+// Correct
+<pre>&lt;button spry-action=":Toggle"&gt;Click&lt;/button&gt;</pre>
+```
+
+## SpryModule
+
+Automatically registers `ComponentEndpoint` at `/_spry/{component-id}/{action}`:
+
+```vala
+application.add_module<SpryModule>();
+```
+
+This enables the declarative `spry-action` attributes to work without manual endpoint registration.

+ 72 - 19
src/Component.vala

@@ -14,11 +14,19 @@ namespace Spry {
         public virtual StatusCode get_status() {
             return StatusCode.OK;
         }
-        public virtual async void populate(Properties properties) throws Error {
+        public virtual async void prepare() throws Error {
+            // No-op default
+        }
+        public virtual void mangle(MarkupDocument final_document) throws Error {
+            // No-op default
+        }
+        public virtual async void handle_action(string action) throws Error {
             // No-op default
         }
         
+        private ComponentUriProvider _uri_provider = inject<ComponentUriProvider>();
         private Catalogue<string, Renderable> _children = new Catalogue<string, Renderable>();
+        private HashSet<Component> _global_sources = new HashSet<Component>();
         private MarkupDocument _instance;
 
         private MarkupDocument instance { get {
@@ -72,39 +80,43 @@ namespace Spry {
             return instance.select_one(@"//*[@sid='$(spry_id)']");
         }
 
-        protected void add_outlet_child(string outlet_id, Component component) {
-            _children.add(outlet_id, component);
+        protected void add_outlet_child(string outlet_id, Renderable renderable) {
+            _children.add(outlet_id, renderable);
         }
 
-        protected void add_outlet_children(string outlet_id, Enumerable<Component> components) {
-            _children.add_all(outlet_id, components);
+        protected void add_outlet_children(string outlet_id, Enumerable<Renderable> renderables) {
+            _children.add_all(outlet_id, renderables);
         }
 
-        protected void set_outlet_children(string outlet_id, Enumerable<Component> components) {
-            _children[outlet_id] = components;
+        protected void set_outlet_children(string outlet_id, Enumerable<Renderable> renderables) {
+            _children[outlet_id] = renderables;
         }
 
-        protected void set_outlet_child(string outlet_id, Component component) {
-            _children[outlet_id] = Iterate.single(component);
+        protected void set_outlet_child(string outlet_id, Renderable renderable) {
+            _children[outlet_id] = Iterate.single(renderable);
         }
 
         protected void clear_outlet_children(string outlet_id) {
             _children.clear_key(outlet_id);
         }
 
-        public MarkupDocument to_document() throws Error {
+        protected void add_globals_from(Component component) {
+            _global_sources.add(component);
+        }
+
+        public async MarkupDocument to_document() throws Error {
+            yield prepare();
             var final_instance = instance.copy();
 
             // Replace outlets
             var outlets = final_instance.select("//spry-outlet");
             foreach (var outlet in outlets) {
-                print(@"loop $(outlet.id)\n");
-                var nodes = _children.get_or_empty(outlet.get_attribute("sid"))
-                    .attempt_select<MarkupDocument>(c => c.to_document())
-                    .to_series() // To series first so we don't delete the document (which deletes the children internally)
-                    .select_many<MarkupNode>(d => d.body.children);
+                var nodes = new Series<MarkupNode>();
+                foreach(var renderable in _children.get_or_empty(outlet.get_attribute("sid"))) {
+                    var document = yield renderable.to_document();
+                    nodes.add_all(document.body.children);
+                }
 
-                print("replace\n");
                 outlet.replace_with_nodes(nodes);
             }
 
@@ -116,15 +128,56 @@ namespace Spry {
             final_instance.select("//spry-control")
                 .iterate(n => n.replace_with_nodes(n.children));
 
-            // Finally, remove all internal SIDs
+
+            var action_nodes = final_instance.select("//*[@spry-action]");
+            foreach(var node in action_nodes) {
+                var action = node.get_attribute("spry-action").split(":", 2);
+                var component_name = action[0].replace(".", "");
+                if(component_name == "") {
+                    component_name = this.get_type().name();
+                }
+                var component_action = action[1];
+
+                node.remove_attribute("spry-action");
+                node.set_attribute("hx-get", _uri_provider.get_action_uri(component_name, component_action));
+            }
+
+            var target_nodes = final_instance.select("//*[@spry-target]");
+            foreach(var node in target_nodes) {
+                var target_node = final_instance.select_one(@"//*[@sid='$(node.get_attribute("spry-target"))']");
+                if(target_node.id == null) {
+                    target_node.id = "_spry-" + Uuid.string_random();
+                }
+
+                node.set_attribute("hx-target", @"#$(target_node.id)");
+                node.remove_attribute("spry-target");
+            }
+
+            var global_nodes = final_instance.select("//*[@spry-global]");
+            foreach(var node in global_nodes) {
+                var key = node.get_attribute("spry-global");
+                node.set_attribute("hx-swap-oob", @"[spry-global=\"$key\"]");
+            }
+
+            // Remove all internal SIDs
             final_instance.select("//*[@sid]")
                 .iterate(n => n.remove_attribute("sid"));
 
+            // Add globals
+            foreach(var source in _global_sources) {
+                var document = yield source.to_document();
+                var globals = document.select("//*[@spry-global]");
+                final_instance.body.append_nodes(globals);
+            }
+
+            // Run any mangling needed
+            mangle(final_instance);
             return final_instance;
         }
 
-        public HttpResult to_result() throws Error {
-            return to_document().to_result(get_status());
+        public async HttpResult to_result() throws Error {
+            var document = yield to_document();
+            return document.to_result(get_status());
         }
 
         private class ComponentTemplate : MarkupTemplate {

+ 63 - 2
src/ComponentEndpoint.vala

@@ -6,8 +6,69 @@ using Astralis;
 
 namespace Spry {
 
-    //  public abstract class ComponentEndpoint : Object, Endpoint {
+    public class ComponentUriProvider : Object {
+
+        private Dictionary<string, Type> type_mapping = new Dictionary<string, Type>();
+        private Dictionary<string, string> name_mapping = new Dictionary<string, string>();
+        private Container container = inject<Container>();
+
+        public Component? get_component_instance(string id, Scope scope) throws Error {
+            Type type;
+            if(type_mapping.try_get(id, out type)) {
+                return (Component)scope.resolve_type(type);
+            }
+            return null;
+        }
+
+        public string get_action_uri(string type_name, string action_name) throws Error {
+            var component_id = upsert(type_name);
+            return @"/_spry/$(component_id)/$action_name";
+        }
+
+        private string upsert(string type_name) throws Error {
+            if(name_mapping.has(type_name)) {
+                return name_mapping[type_name];
+            }
+
+            var registration = container.get_all_registrations()
+                .where(r => r.implementation_type.is_a(typeof(Component)))
+                .first_or_default(r => r.implementation_type.name() == type_name);
+
+            if(registration == null) {
+                throw new ContainerError.NOT_REGISTERED(@"Could not find registered component with name $(type_name)");
+            }
+
+            var uuid = Uuid.string_random();
+            type_mapping[uuid] = registration.implementation_type;
+            name_mapping[type_name] = uuid;
+            return uuid;
+        }
+
+    }
+
+    public class ComponentEndpoint : Object, Endpoint {
+
+        private ComponentUriProvider component_uri_provider = inject<ComponentUriProvider>();
+        private Scope scope = inject<Scope>();
+
+        public async Astralis.HttpResult handle_request (HttpContext http_context, RouteContext route_context) throws Error {
+            var component_id = route_context.mapped_parameters.get_or_default ("component-id");
+            var action_name = route_context.mapped_parameters.get_or_default ("action");
+            if(component_id == null) {
+                return new HttpStringResult ("Missing component ID", StatusCode.BAD_REQUEST);
+            }
+            if(component_id == null) {
+                return new HttpStringResult ("Missing action", StatusCode.BAD_REQUEST);
+            }
+            var component = component_uri_provider.get_component_instance(component_id, scope);
+            if(component == null) {
+                return new HttpStringResult ("Invalid component ID", StatusCode.NOT_FOUND);
+            }
+            
+            yield component.handle_action(action_name);
+            return yield component.to_result();
+        }
         
-    //  }
+    }
 
 }

+ 26 - 0
src/ComponentFactory.vala

@@ -0,0 +1,26 @@
+using Inversion;
+namespace Spry {
+
+    public class ComponentFactory : Object {
+
+        private Scope scope = inject<Scope>();
+        
+        public Component create_type(Type component_type) throws Error {
+            if(!component_type.is_a(typeof(Component))) {
+                throw new ContainerError.INCOMPATIBLE_TYPE(@"Type $(component_type.name()) is not a Component");
+            }
+            var component_registration = scope.get_registration(component_type);
+            if(component_registration.lifecycle != Lifecycle.TRANSIENT) {
+                throw new ContainerError.ILLEGAL_LIFECYCLE_COMBINATION("Spry.ComponentFactory can only create new instances of components that are registered in the container as transient.");
+            }
+
+            return (Component)scope.resolve_registration(component_registration);
+        }
+
+        public new T create<T>() throws Error {
+            return (T)create_type(typeof(T));
+        } 
+
+    }
+
+}

+ 1 - 1
src/Renderable.vala

@@ -3,7 +3,7 @@ namespace Spry {
 
     public interface Renderable : Object {
 
-        public abstract MarkupDocument to_document() throws Error;
+        public abstract async MarkupDocument to_document() throws Error;
 
     }
 

+ 18 - 0
src/Spry.vala

@@ -0,0 +1,18 @@
+using Inversion;
+using Astralis;
+
+namespace Spry {
+
+    public class SpryModule : Object, Module {
+
+        public void register_components (Container container) throws Error {
+            container.register_singleton<ComponentUriProvider>();
+            container.register_scoped<ComponentFactory>();
+            container.register_scoped<ComponentEndpoint>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_spry/{component-id}/{action}"));
+        }
+
+    }
+
+}

+ 2 - 0
src/meson.build

@@ -1,5 +1,7 @@
 sources = files(
+    'Spry.vala',
     'Component.vala',
+    'ComponentFactory.vala',
     'Renderable.vala',
     'ComponentEndpoint.vala',
     'Context.vala',