Pārlūkot izejas kodu

feat(spry): add declarative child components with spry-component and PageComponent

Introduce declarative component composition using <spry-component name="..." sid="..."/>
tags in markup, replacing the manual outlet-based approach for static child components.

Key changes:
- Add get_component_child<T>(sid) to access declared child components in prepare()
- Add PageComponent base class that combines Component and Endpoint roles
- Add SpryConfigurator.add_component<T>() and add_page<T>() registration methods
- Add ComponentFactory.create_by_name() for resolving components by name
- Refactor to_document() into separate transform methods for better organization

spry-outlet is now reserved for dynamic lists while spry-component handles
known single child components declaratively in markup.
Billy Barrow 1 nedēļu atpakaļ
vecāks
revīzija
9975141bc4
5 mainītis faili ar 280 papildinājumiem un 106 dzēšanām
  1. 46 73
      examples/TodoComponent.vala
  2. 106 0
      important-details.md
  3. 103 32
      src/Component.vala
  4. 14 1
      src/ComponentFactory.vala
  5. 11 0
      src/Spry.vala

+ 46 - 73
examples/TodoComponent.vala

@@ -10,8 +10,10 @@ using Spry;
  * Demonstrates using the Spry.Component class with IoC composition.
  * Shows how to:
  *   - Build a complete CRUD interface with Components
+ *   - Use <spry-component name="MyComponent"> for declarative child components
+ *   - Use get_component_child<T>(sid) to access child components
+ *   - Use <spry-outlet> for dynamic lists (multiple items from data)
  *   - 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
  * 
@@ -278,8 +280,16 @@ class FeaturesComponent : Component {
                 <code>class MyComponent : Component { public override string markup { get { return "..."; } } }</code>
             </div>
             <div class="feature">
-                <strong>Using Outlets:</strong>
-                <code>&lt;spry-outlet sid="content"/&gt;</code>
+                <strong>Declarative Child Components:</strong>
+                <code>&lt;spry-component name="HeaderComponent" sid="header"/&gt;</code>
+            </div>
+            <div class="feature">
+                <strong>Accessing Child Components:</strong>
+                <code>var header = get_component_child&lt;HeaderComponent&gt;("header");</code>
+            </div>
+            <div class="feature">
+                <strong>Using Outlets (for lists):</strong>
+                <code>&lt;spry-outlet sid="items"/&gt;</code>
             </div>
             <div class="feature">
                 <strong>Preparing Templates:</strong>
@@ -291,11 +301,7 @@ class FeaturesComponent : Component {
             </div>
             <div class="feature">
                 <strong>IoC Injection:</strong>
-                <code>private ComponentFactory factory = inject<ComponentFactory>();</code>
-            </div>
-            <div class="feature">
-                <strong>Creating Components:</strong>
-                <code>var child = factory.create&lt;MyComponent&gt;();</code>
+                <code>private ComponentFactory factory = inject&lt;ComponentFactory&gt;();</code>
             </div>
         </div>
         """;
@@ -318,29 +324,16 @@ class FooterComponent : Component {
 }
 
 /**
- * PageLayoutComponent - The main page structure.
+ * TodoPage - The main page structure.
+ *
+ * Inherits from PageComponent to act as both a Component and Endpoint.
+ * Uses <spry-component name="..."> syntax for declarative child components.
+ * Child components are accessed via get_component_child<T>(sid) in prepare().
  */
-class PageLayoutComponent : Component {
-    
-    public void set_header(Renderable component) {
-        set_outlet_child("header", component);
-    }
+class TodoPage : PageComponent {
     
-    public void set_todo_list(Renderable component) {
-        set_outlet_child("todo-list", component);
-    }
-    
-    public void set_add_form(Renderable component) {
-        set_outlet_child("add-form", component);
-    }
-    
-    public void set_features(Renderable component) {
-        set_outlet_child("features", component);
-    }
-    
-    public void set_footer(Renderable component) {
-        set_outlet_child("footer", component);
-    }
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
     
     public override string markup { get {
         return """
@@ -380,31 +373,20 @@ class PageLayoutComponent : Component {
             </style>
         </head>
         <body>
-            <spry-outlet sid="header"/>
-            <spry-outlet sid="todo-list"/>
-            <spry-outlet sid="add-form"/>
-            <spry-outlet sid="features"/>
-            <spry-outlet sid="footer"/>
+            <spry-component name="HeaderComponent" sid="header"/>
+            <spry-component name="TodoListComponent" sid="todo-list"/>
+            <spry-component name="AddFormComponent" sid="add-form"/>
+            <spry-component name="FeaturesComponent" sid="features"/>
+            <spry-component name="FooterComponent" sid="footer"/>
         </body>
         </html>
         """;
     }}
-}
-
-// 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 page layout
-        var page = factory.create<PageLayoutComponent>();
-        
-        // Create header - prepare() fetches stats from store automatically
-        page.set_header(factory.create<HeaderComponent>());
-        
-        // Create todo list - only need to set item_id, prepare() handles the rest
-        var todo_list = factory.create<TodoListComponent>();
+    // Called before serialization to populate the todo list
+    public override async void prepare() throws Error {
+        // Get the TodoListComponent child and populate it
+        var todo_list = get_component_child<TodoListComponent>("todo-list");
         
         var count = todo_store.count();
         if (count == 0) {
@@ -418,19 +400,6 @@ class HomePageEndpoint : Object, Endpoint {
             });
             todo_list.set_items(items);
         }
-        page.set_todo_list(todo_list);
-        
-        // Add form
-        page.set_add_form(factory.create<AddFormComponent>());
-        
-        // Features
-        page.set_features(factory.create<FeaturesComponent>());
-        
-        // Footer
-        page.set_footer(factory.create<FooterComponent>());
-        
-        // to_result() handles all outlet replacement automatically
-        return yield page.to_result();
     }
 }
 
@@ -490,18 +459,22 @@ void main(string[] args) {
         // 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>();
+        // Configure Spry components and pages
+        var spry_cfg = application.configure_with<SpryConfigurator>();
+        
+        // Register child components
+        spry_cfg.add_component<EmptyListComponent>();
+        spry_cfg.add_component<TodoItemComponent>();
+        spry_cfg.add_component<TodoListComponent>();
+        spry_cfg.add_component<HeaderComponent>();
+        spry_cfg.add_component<AddFormComponent>();
+        spry_cfg.add_component<FeaturesComponent>();
+        spry_cfg.add_component<FooterComponent>();
+        
+        // Register the page (acts as both Component and Endpoint)
+        spry_cfg.add_page<TodoPage>(new EndpointRoute("/"));
         
-        // Register endpoints
-        application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));
+        // Register JSON API endpoint
         application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));
         
         application.run();

+ 106 - 0
important-details.md

@@ -276,3 +276,109 @@ application.add_module<SpryModule>();
 ```
 
 This enables the declarative `spry-action` attributes to work without manual endpoint registration.
+
+## Declarative Child Components with `<spry-component>`
+
+### When to Use `<spry-component>` vs `<spry-outlet>`
+
+- **`<spry-component name="ComponentName" sid="..."/>`** - For single, known child components
+- **`<spry-outlet sid="..."/>`** - For dynamic lists (multiple items from data)
+
+### Declaring Child Components
+
+Use `<spry-component>` in markup for declarative composition:
+
+```vala
+class TodoPage : PageComponent {
+    public override string markup { get {
+        return """
+        <!DOCTYPE html>
+        <html>
+        <body>
+            <spry-component name="HeaderComponent" sid="header"/>
+            <spry-component name="TodoListComponent" sid="todo-list"/>
+            <spry-component name="AddFormComponent" sid="add-form"/>
+            <spry-component name="FooterComponent" sid="footer"/>
+        </body>
+        </html>
+        """;
+    }}
+}
+```
+
+### Accessing Child Components with `get_component_child<T>()`
+
+Use `get_component_child<T>(sid)` in `prepare()` to access and configure child components:
+
+```vala
+class TodoPage : PageComponent {
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override async void prepare() throws Error {
+        // Get child component and configure it
+        var todo_list = get_component_child<TodoListComponent>("todo-list");
+        
+        // Populate the list (which still uses spry-outlet for dynamic items)
+        var items = new Series<Renderable>();
+        todo_store.all().iterate((item) => {
+            var component = factory.create<TodoItemComponent>();
+            component.item_id = item.id;
+            items.add(component);
+        });
+        todo_list.set_items(items);
+    }
+}
+```
+
+## PageComponent - Combining Component and Endpoint
+
+`PageComponent` is a base class that acts as both a `Component` AND an `Endpoint`. This eliminates the need for separate endpoint classes:
+
+```vala
+// Before: Separate endpoint class
+class HomePageEndpoint : Object, Endpoint {
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        var page = factory.create<PageLayoutComponent>();
+        return yield page.to_result();
+    }
+}
+
+// After: PageComponent handles both roles
+class TodoPage : PageComponent {
+    public override string markup { get { return "..."; } }
+    public override async void prepare() throws Error { /* ... */ }
+}
+```
+
+## SpryConfigurator - Component and Page Registration
+
+Use `SpryConfigurator` to register components and pages in a structured way:
+
+```vala
+// Get the configurator
+var spry_cfg = application.configure_with<SpryConfigurator>();
+
+// Register child components (transient lifecycle)
+spry_cfg.add_component<HeaderComponent>();
+spry_cfg.add_component<TodoListComponent>();
+spry_cfg.add_component<TodoItemComponent>();
+spry_cfg.add_component<AddFormComponent>();
+spry_cfg.add_component<FooterComponent>();
+
+// Register pages (scoped lifecycle, acts as Endpoint)
+spry_cfg.add_page<TodoPage>(new EndpointRoute("/"));
+
+// Other endpoints still use add_endpoint
+application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));
+```
+
+### Registration Methods
+
+| Method | Lifecycle | Use Case |
+|--------|-----------|----------|
+| `add_component<T>()` | Transient | Child components created via factory |
+| `add_page<T>(route)` | Scoped | Page components that act as endpoints |
+| `add_template<T>(prefix)` | Transient | Page templates for layout wrapping |

+ 103 - 32
src/Component.vala

@@ -5,6 +5,12 @@ using Astralis;
 
 namespace Spry {
 
+    public errordomain ComponentError {
+        INVALID_TYPE,
+        ELEMENT_NOT_FOUND,
+        TYPE_NOT_FOUND;
+    }
+
     public abstract class Component : Object, Renderable {
         
         private static Dictionary<Type, ComponentTemplate> templates;
@@ -29,7 +35,9 @@ namespace Spry {
         
         private PathProvider _path_provider = inject<PathProvider>();
         private ContinuationProvider _continuation_provider = inject<ContinuationProvider>();
+        private ComponentFactory _component_factory = inject<ComponentFactory>();
         private Catalogue<string, Renderable> _children = new Catalogue<string, Renderable>();
+        private Dictionary<string, Component> _child_components = new Dictionary<string, Component>();
         private HashSet<Component> _global_sources = new HashSet<Component>();
         private MarkupDocument _instance;
 
@@ -108,32 +116,93 @@ namespace Spry {
             _global_sources.add(component);
         }
 
+        protected T get_component_child<T>(string sid) throws Error {
+            var node = query_one(@"//spry-component[@sid='$sid']");
+            if(node == null) {
+                throw new ComponentError.ELEMENT_NOT_FOUND(@"No spry-component element with sid '$sid' found.");
+            }
+
+            var component = get_component_instance_from_component_node(node);
+            if(!component.get_type().is_a(typeof(T))) {
+                throw new ComponentError.INVALID_TYPE(@"Component type $(component.get_type().name()) is not a $(typeof(T).name())");
+            }
+            return component;
+        }
+
         public async MarkupDocument to_document() throws Error {
             yield prepare();
             var final_instance = instance.copy();
 
-            // Replace outlets
-            var outlets = final_instance.select("//spry-outlet");
+            remove_hidden_blocks(final_instance);
+            yield transform_outlets(final_instance);
+            yield transform_components(final_instance);
+            replace_control_blocks(final_instance);
+            transform_action_nodes(final_instance);
+            transform_target_nodes(final_instance);
+            transform_global_nodes(final_instance);
+            transform_script_nodes(final_instance);
+            transform_continuation_nodes(final_instance);
+            remove_internal_sids(final_instance);
+            yield append_globals(final_instance);
+
+            return final_instance;
+        }
+
+
+        public async HttpResult to_result() throws Error {
+            var document = yield to_document();
+            return document.to_result(get_status());
+        }
+
+        private class ComponentTemplate : MarkupTemplate {
+            private string _markup;
+            protected override string markup { get { return _markup; } }
+
+            public ComponentTemplate(string markup) {
+                this._markup = markup;
+            }
+        }
+
+        private Component get_component_instance_from_component_node(MarkupNode node) throws Error {
+            Component component;
+            // If no SID, create one to keep track of the instance
+            var sid = node.get_attribute("sid");
+            if(sid == null) {
+                sid = Uuid.string_random();
+                node.set_attribute("sid", sid);
+            }
+
+            if(!_child_components.try_get(sid, out component)) {
+                component = _component_factory.create_by_name(node.get_attribute("name"));
+                _child_components[sid] = component;
+            }
+            return component;
+        }
+
+        private async void transform_outlets(MarkupDocument doc) throws Error {
+            var outlets = doc.select("//spry-outlet");
             foreach (var outlet in outlets) {
                 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);
                 }
-
                 outlet.replace_with_nodes(nodes);
             }
+        }
 
-            // Remove hidden blocks
-            final_instance.select("//*[@spry-hidden]")
+        private void remove_hidden_blocks(MarkupDocument doc) {
+            doc.select("//*[@spry-hidden]")
                 .iterate(n => n.remove());
+        }
 
-            // Replace control blocks with their children
-            final_instance.select("//spry-control")
+        private void replace_control_blocks(MarkupDocument doc) {
+            doc.select("//spry-control")
                 .iterate(n => n.replace_with_nodes(n.children));
+        }
 
-
-            var action_nodes = final_instance.select("//*[@spry-action]");
+        private void transform_action_nodes(MarkupDocument doc) throws Error {
+            var action_nodes = doc.select("//*[@spry-action]");
             foreach(var node in action_nodes) {
                 var action = node.get_attribute("spry-action").split(":", 2);
                 var component_name = action[0].replace(".", "");
@@ -145,10 +214,12 @@ namespace Spry {
                 node.remove_attribute("spry-action");
                 node.set_attribute("hx-get", _path_provider.get_action_path(component_name, component_action));
             }
+        }
 
-            var target_nodes = final_instance.select("//*[@spry-target]");
+        private void transform_target_nodes(MarkupDocument doc) {
+            var target_nodes = doc.select("//*[@spry-target]");
             foreach(var node in target_nodes) {
-                var target_node = final_instance.select_one(@"//*[@sid='$(node.get_attribute("spry-target"))']");
+                var target_node = doc.select_one(@"//*[@sid='$(node.get_attribute("spry-target"))']");
                 if(target_node.id == null) {
                     target_node.id = "_spry-" + Uuid.string_random();
                 }
@@ -156,14 +227,18 @@ namespace Spry {
                 node.set_attribute("hx-target", @"#$(target_node.id)");
                 node.remove_attribute("spry-target");
             }
+        }
 
-            var global_nodes = final_instance.select("//*[@spry-global]");
+        private void transform_global_nodes(MarkupDocument doc) {
+            var global_nodes = doc.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\"]");
             }
+        }
 
-            var script_nodes = final_instance.select("//script[@spry-res]");
+        private void transform_script_nodes(MarkupDocument doc) {
+            var script_nodes = doc.select("//script[@spry-res]");
             foreach(var node in script_nodes) {
                 var res = node.get_attribute("spry-res");
                 if(res != null) {
@@ -171,8 +246,10 @@ namespace Spry {
                 }
                 node.remove_attribute("spry-res");
             }
+        }
 
-            var continuation_nodes = final_instance.select("//*[@spry-continuation]");
+        private void transform_continuation_nodes(MarkupDocument doc) {
+            var continuation_nodes = doc.select("//*[@spry-continuation]");
             foreach(var node in continuation_nodes) {
                 var path = _continuation_provider.get_continuation_path(this);
                 node.set_attribute("hx-ext", "sse");
@@ -180,37 +257,31 @@ namespace Spry {
                 node.set_attribute("sse-close", "_spry-close");
                 node.remove_attribute("spry-continuation");
             }
+        }
 
-            // Remove all internal SIDs
-            final_instance.select("//*[@sid]")
+        private void remove_internal_sids(MarkupDocument doc) {
+            doc.select("//*[@sid]")
                 .iterate(n => n.remove_attribute("sid"));
+        }
 
-            // Add globals
+        private async void append_globals(MarkupDocument doc) throws Error {
             foreach(var source in _global_sources) {
                 var document = yield source.to_document();
                 var globals = document.select("//*[@spry-global]");
-                final_instance.body.append_nodes(globals);
+                doc.body.append_nodes(globals);
             }
-
-            return final_instance;
-        }
-
-        public async HttpResult to_result() throws Error {
-            var document = yield to_document();
-            return document.to_result(get_status());
         }
 
-        private class ComponentTemplate : MarkupTemplate {
-            private string _markup;
-            protected override string markup { get { return _markup; } }
-
-            public ComponentTemplate(string markup) {
-                this._markup = markup;
+        private async void transform_components(MarkupDocument doc) throws Error {
+            var components = doc.select("//spry-component");
+            foreach (var component_node in components) {
+                var component = get_component_instance_from_component_node(component_node);
+                var document = yield component.to_document();
+                component_node.replace_with_nodes(document.body.children);
             }
         }
 
 
-
     }
 
 }

+ 14 - 1
src/ComponentFactory.vala

@@ -17,10 +17,23 @@ namespace Spry {
             return (Component)scope.resolve_registration(component_registration);
         }
 
-        public new T create<T>() throws Error {
+        public T create<T>() throws Error {
             return (T)create_type(typeof(T));
         } 
 
+        public Component create_by_name(string name) throws Error {
+            var type_name = name.replace(".", "");
+            var registration = scope.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 ComponentError.TYPE_NOT_FOUND(@"Could not find type $type_name");
+            }
+
+            return (Component)scope.resolve_registration(registration);
+        }
+
     }
 
 }

+ 11 - 0
src/Spry.vala

@@ -38,6 +38,17 @@ namespace Spry {
                 .with_metadata<TemplateRoutePrefix>(new TemplateRoutePrefix(prefix));
         }
 
+        public void add_page<T>(EndpointRoute route) {
+            container.register_scoped<T>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(route);
+        }
+
+        public void add_component<T>() {
+            container.register_transient<T>();
+        }
+
+
     }
 
 }