Ver Fonte

feat(spry): add template-based page component support

Add PageComponent and PageTemplate classes to support template-based
page rendering. This enables defining reusable page templates with
placeholder slots that can be filled by components.

- Add PageComponent for managing template-based pages
- Add PageTemplate for defining page structure templates
- Add TemplateExample demonstrating template usage
- Remove unused mangle method from base Component

BREAKING CHANGE: The virtual mangle method has been removed from
Component base class. Subclasses overriding this method will need
to use alternative approaches for document manipulation.
Billy Barrow há 1 semana atrás
pai
commit
ea0d83d03d

+ 687 - 0
examples/TemplateExample.vala

@@ -0,0 +1,687 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Spry;
+
+/**
+ * TemplateExample.vala - Demonstrates PageComponent and PageTemplate usage
+ * 
+ * This example shows how to create template-based pages where:
+ * - MainLayoutTemplate provides the base HTML structure with header and footer
+ * - AdminSectionTemplate adds an admin navigation sidebar (nested template)
+ * - PageComponents render their content into template outlets
+ * 
+ * Route structure:
+ *   /              -> HomePage (uses MainLayoutTemplate)
+ *   /about         -> AboutPage (uses MainLayoutTemplate)
+ *   /admin         -> AdminDashboardPage (uses AdminSectionTemplate -> MainLayoutTemplate)
+ *   /admin/users   -> AdminUsersPage (uses AdminSectionTemplate -> MainLayoutTemplate)
+ */
+
+// =============================================================================
+// STYLESHEETS - CSS content served as FastResources
+// =============================================================================
+
+/**
+ * Main CSS - Base styles for all pages
+ */
+private const string MAIN_CSS = """
+/* Base Reset & Typography */
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body {
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+    line-height: 1.6;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    min-height: 100vh;
+}
+
+/* Header */
+header {
+    background: rgba(44, 62, 80, 0.95);
+    color: white;
+    padding: 1rem 2rem;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    backdrop-filter: blur(10px);
+    box-shadow: 0 2px 20px rgba(0,0,0,0.2);
+}
+header nav a {
+    color: white;
+    margin-right: 1.5rem;
+    text-decoration: none;
+    font-weight: 500;
+    transition: opacity 0.2s;
+}
+header nav a:hover { opacity: 0.8; }
+header nav a:last-child { margin-right: 0; }
+header .visit-info {
+    font-size: 0.85rem;
+    opacity: 0.8;
+}
+
+/* Main Content */
+main.container {
+    max-width: 1200px;
+    margin: 2rem auto;
+    padding: 0 1rem;
+}
+
+/* Cards */
+.card {
+    background: white;
+    border-radius: 12px;
+    padding: 2rem;
+    margin-bottom: 1.5rem;
+    box-shadow: 0 10px 40px rgba(0,0,0,0.15);
+}
+
+/* Typography */
+h1 { color: #2c3e50; margin-bottom: 1rem; }
+h2 { color: #34495e; margin-bottom: 0.75rem; margin-top: 1.5rem; }
+h3 { color: #34495e; margin-bottom: 0.5rem; margin-top: 1rem; }
+p { color: #555; margin-bottom: 1rem; }
+ul, ol { margin-left: 1.5rem; margin-bottom: 1rem; }
+li { margin-bottom: 0.5rem; color: #555; }
+
+/* Links */
+a { color: #667eea; text-decoration: none; transition: color 0.2s; }
+a:hover { color: #764ba2; text-decoration: underline; }
+
+/* Code */
+code {
+    background: #f0f0f0;
+    padding: 0.2rem 0.5rem;
+    border-radius: 4px;
+    font-size: 0.9em;
+    color: #e74c3c;
+}
+pre {
+    background: #263238;
+    color: #aed581;
+    padding: 1rem;
+    border-radius: 8px;
+    overflow-x: auto;
+    font-size: 0.9rem;
+    margin: 1rem 0;
+}
+
+/* Footer */
+footer {
+    background: rgba(44, 62, 80, 0.9);
+    color: rgba(255,255,255,0.7);
+    padding: 1.5rem;
+    text-align: center;
+    margin-top: 2rem;
+    backdrop-filter: blur(10px);
+}
+
+/* Buttons & Forms */
+button {
+    background: #667eea;
+    color: white;
+    border: none;
+    padding: 0.75rem 1.5rem;
+    border-radius: 6px;
+    cursor: pointer;
+    font-size: 1rem;
+    font-weight: 500;
+    transition: all 0.2s;
+}
+button:hover {
+    background: #5a6fd6;
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+input[type="text"] {
+    padding: 0.75rem;
+    border: 2px solid #e0e0e0;
+    border-radius: 6px;
+    font-size: 1rem;
+    transition: border-color 0.2s;
+}
+input[type="text"]:focus {
+    outline: none;
+    border-color: #667eea;
+}
+""";
+
+/**
+ * Admin CSS - Additional styles for admin section
+ */
+private const string ADMIN_CSS = """
+/* Admin Section Layout */
+.admin-section {
+    display: flex;
+    gap: 2rem;
+    margin-top: 1rem;
+}
+
+/* Admin Sidebar */
+.admin-sidebar {
+    width: 220px;
+    background: rgba(52, 73, 94, 0.95);
+    padding: 1.5rem;
+    border-radius: 12px;
+    box-shadow: 0 4px 20px rgba(0,0,0,0.15);
+    backdrop-filter: blur(10px);
+}
+.admin-sidebar h3 {
+    color: white;
+    margin-bottom: 1rem;
+    font-size: 1.1rem;
+    border-bottom: 1px solid rgba(255,255,255,0.2);
+    padding-bottom: 0.75rem;
+}
+.admin-sidebar ul {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.admin-sidebar li { margin-bottom: 0.5rem; }
+.admin-sidebar a {
+    color: rgba(255,255,255,0.7);
+    display: block;
+    padding: 0.5rem 0.75rem;
+    border-radius: 6px;
+    transition: all 0.2s;
+}
+.admin-sidebar a:hover {
+    color: white;
+    background: rgba(255,255,255,0.1);
+    text-decoration: none;
+}
+
+/* Admin Content Area */
+.admin-content {
+    flex: 1;
+    min-width: 0;
+}
+
+/* Dashboard Stats */
+.dashboard-stats {
+    display: flex;
+    gap: 1.5rem;
+    flex-wrap: wrap;
+}
+.stat-card {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+    padding: 1.5rem;
+    border-radius: 12px;
+    min-width: 180px;
+    box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
+}
+.stat-card h3 {
+    color: rgba(255,255,255,0.9);
+    font-size: 0.85rem;
+    text-transform: uppercase;
+    letter-spacing: 1px;
+    margin: 0 0 0.5rem 0;
+}
+.stat-card .stat-value {
+    font-size: 2.5rem;
+    font-weight: bold;
+}
+
+/* User List */
+.user-list {
+    margin: 1.5rem 0;
+}
+.user-item {
+    display: flex;
+    align-items: center;
+    gap: 1rem;
+    padding: 1rem 1.25rem;
+    background: white;
+    margin-bottom: 0.75rem;
+    border-radius: 8px;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+    transition: transform 0.2s, box-shadow 0.2s;
+}
+.user-item:hover {
+    transform: translateX(4px);
+    box-shadow: 0 4px 12px rgba(0,0,0,0.12);
+}
+.user-item .user-name {
+    flex: 1;
+    font-weight: 500;
+    color: #2c3e50;
+}
+.user-item .user-status {
+    color: #27ae60;
+    font-size: 0.9rem;
+    font-weight: 500;
+}
+
+/* Add Form */
+.add-form {
+    display: flex;
+    gap: 0.75rem;
+    margin-top: 1.5rem;
+}
+.add-form input[type="text"] {
+    flex: 1;
+    max-width: 300px;
+}
+
+/* Navigation Links */
+.nav-link {
+    display: inline-block;
+    margin-top: 1.5rem;
+    color: #667eea;
+    font-weight: 500;
+}
+.nav-link:hover { color: #764ba2; }
+""";
+
+// =============================================================================
+// STORES - Simple data stores for demonstration
+// =============================================================================
+
+class AppState : Object {
+    public int visit_count { get; set; }
+    public string last_visit { get; set; }
+}
+
+class UserStore : Object {
+    private Series<string> users = new Series<string>();
+
+    construct {
+        users.add("Alice");
+        users.add("Bob");
+        users.add("Charlie");
+    }
+
+    public Enumerable<string> get_all() {
+        return users;
+    }
+
+    public void add_user(string name) {
+        users.add(name);
+    }
+
+    public int count() {
+        return (int)users.length;
+    }
+}
+
+// =============================================================================
+// TEMPLATES - Provide reusable page layouts
+// =============================================================================
+
+/**
+ * MainLayoutTemplate - The base template for all pages
+ * 
+ * Provides:
+ * - HTML document structure
+ * - Common <head> elements (scripts, styles)
+ * - Site-wide header with navigation
+ * - Site-wide footer
+ * 
+ * Uses <spry-template-outlet> where child content/templates will be inserted
+ */
+class MainLayoutTemplate : PageTemplate {
+    
+    private AppState app_state = inject<AppState>();
+    
+    public override string markup { get {
+        return """
+        <!DOCTYPE html>
+        <html lang="en">
+        <head>
+            <meta charset="UTF-8">
+            <meta name="viewport" content="width=device-width, initial-scale=1.0">
+            <title>Spry Template Example</title>
+            <link rel="stylesheet" href="/styles/main.css">
+            <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
+        </head>
+        <body>
+            <header>
+                <nav>
+                    <a href="/">Home</a>
+                    <a href="/about">About</a>
+                    <a href="/admin">Admin</a>
+                </nav>
+                <small class="visit-info" sid="visit-info"></small>
+            </header>
+            <main class="container">
+                <spry-template-outlet />
+            </main>
+            <footer>
+                <p>Built with Spry Framework - Template Example</p>
+            </footer>
+        </body>
+        </html>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        this["visit-info"].text_content = @"Visits: $(app_state.visit_count) | Last: $(app_state.last_visit)";
+    }
+}
+
+/**
+ * AdminSectionTemplate - A nested template for admin pages
+ * 
+ * This template is applied to routes starting with /admin
+ * It adds an admin sidebar navigation around the page content
+ * 
+ * Templates are applied in order of rank (based on prefix length):
+ * 1. MainLayoutTemplate (rank 0, prefix "") - outermost
+ * 2. AdminSectionTemplate (rank 1, prefix "/admin") - nested inside main
+ * 3. PageComponent - innermost content
+ */
+class AdminSectionTemplate : PageTemplate {
+    
+    public override string markup { get {
+        return """
+        <head>
+            <link rel="stylesheet" href="/styles/admin.css">
+        </head>
+        <div class="admin-section">
+            <aside class="admin-sidebar">
+                <h3>Admin Menu</h3>
+                <ul>
+                    <li><a href="/admin">Dashboard</a></li>
+                    <li><a href="/admin/users">Users</a></li>
+                </ul>
+            </aside>
+            <div class="admin-content">
+                <spry-template-outlet />
+            </div>
+        </div>
+        """;
+    }}
+}
+
+// =============================================================================
+// PAGE COMPONENTS - Render content into templates
+// =============================================================================
+
+/**
+ * HomePage - The main landing page
+ * 
+ * Extends PageComponent (not Component) to automatically use template rendering
+ * The handle_request method in PageComponent:
+ * 1. Finds all matching templates (MainLayoutTemplate matches "")
+ * 2. Renders each template in order
+ * 3. Inserts each rendered content into the previous template's outlet
+ */
+class HomePage : PageComponent {
+    
+    private AppState app_state = inject<AppState>();
+    
+    public const string ROUTE = "/";
+    
+    public override string markup { get {
+        return """
+        <div class="card" sid="home">
+            <h1>Welcome to Spry!</h1>
+            <p>This example demonstrates template-based pages using <code>PageComponent</code> and <code>PageTemplate</code>.</p>
+            
+            <h2>Features</h2>
+            <ul>
+                <li><strong>MainLayoutTemplate</strong> - Provides base HTML, header, footer</li>
+                <li><strong>AdminSectionTemplate</strong> - Adds sidebar for /admin/* routes</li>
+                <li><strong>PageComponent</strong> - Pages automatically inherit templates</li>
+            </ul>
+            
+            <h2>Try These Pages</h2>
+            <ul>
+                <li><a href="/about">About Page</a> - Uses main layout only</li>
+                <li><a href="/admin">Admin Dashboard</a> - Uses admin template + main layout</li>
+                <li><a href="/admin/users">Admin Users</a> - Interactive user list</li>
+            </ul>
+            
+            <p sid="visit-count"></p>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        app_state.visit_count++;
+        app_state.last_visit = new DateTime.now_local().format("%H:%M:%S");
+        this["visit-count"].text_content = @"You have visited $(app_state.visit_count) times.";
+    }
+}
+
+/**
+ * AboutPage - A simple about page
+ */
+class AboutPage : PageComponent {
+    
+    public const string ROUTE = "/about";
+    
+    public override string markup { get {
+        return """
+        <div class="card">
+            <h1>About Spry Templates</h1>
+            
+            <h2>How Templates Work</h2>
+            <p>Templates use <code><spry-template-outlet /></code> to define where child content will be inserted.</p>
+            
+            <h3>Template Resolution</h3>
+            <p>When a <code>PageComponent</code> handles a request:</p>
+            <ol>
+                <li>It finds all registered <code>PageTemplate</code>s matching the route</li>
+                <li>Templates are ordered by their prefix length (rank)</li>
+                <li>Each template is rendered, with content nested into outlets</li>
+            </ol>
+            
+            <h3>Route Matching</h3>
+            <pre>
+Prefix "" matches all routes
+Prefix "/admin" matches /admin and /admin/*
+Prefix "/admin/users" matches /admin/users only
+            </pre>
+            
+            <a href="/" class="nav-link">Back to Home</a>
+        </div>
+        """;
+    }}
+}
+
+/**
+ * AdminDashboardPage - Admin dashboard with statistics
+ */
+class AdminDashboardPage : PageComponent {
+    
+    private UserStore user_store = inject<UserStore>();
+    
+    public const string ROUTE = "/admin";
+    
+    public override string markup { get {
+        return """
+        <div class="card">
+            <h1>Admin Dashboard</h1>
+            
+            <div class="dashboard-stats">
+                <div class="stat-card">
+                    <h3>Total Users</h3>
+                    <div class="stat-value" sid="user-count"></div>
+                </div>
+            </div>
+            
+            <a href="/" class="nav-link">Back to Home</a>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        this["user-count"].text_content = user_store.count().to_string();
+    }
+}
+
+/**
+ * AdminUsersPage - Admin page with interactive user list
+ * 
+ * Demonstrates HTMX interactions within a templated page
+ */
+class AdminUsersPage : PageComponent {
+    
+    private UserStore user_store = inject<UserStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    private HttpContext http_context = inject<HttpContext>();
+    
+    public const string ROUTE = "/admin/users";
+    
+    public override string markup { get {
+        return """
+        <div class="card">
+            <h1>User Management</h1>
+            
+            <div class="user-list" sid="user-list">
+                <!-- Users rendered here -->
+            </div>
+            
+            <form class="add-form" sid="add-form" spry-action=":AddUser" spry-target="user-list" hx-swap="innerHTML">
+                <input type="text" name="username" placeholder="Enter username" required>
+                <button type="submit">Add User</button>
+            </form>
+            
+            <a href="/admin" class="nav-link">Back to Dashboard</a>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        refresh_user_list();
+    }
+    
+    private void refresh_user_list() throws Error {
+        var users = user_store.get_all();
+        var items = new Series<Renderable>();
+        
+        users.iterate((name) => {
+            var item = factory.create<UserItemComponent>();
+            item.username = name;
+            items.add(item);
+        });
+        
+        set_outlet_children("user-list", items);
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        if (action == "AddUser") {
+            var username = http_context.request.query_params.get_any_or_default("username");
+            if (username != null && username.length > 0) {
+                user_store.add_user(username);
+            }
+            refresh_user_list();
+        }
+    }
+}
+
+/**
+ * UserItemComponent - Individual user item in the list
+ */
+class UserItemComponent : Component {
+    
+    public string username { set; get; }
+    
+    public override string markup { get {
+        return """
+        <div class="user-item" sid="user-item">
+            <span class="user-name" sid="name"></span>
+            <span class="user-status">Active</span>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        this["name"].text_content = username;
+    }
+}
+
+// =============================================================================
+// APPLICATION SETUP
+// =============================================================================
+
+void main(string[] args) {
+    int port = args.length > 1 ? int.parse(args[1]) : 8080;
+    
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("            Spry Template Example (PageComponent + PageTemplate)\n");
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("  Port: %d\n", port);
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("  Endpoints:\n");
+    print("    /              - Home page (MainLayoutTemplate)\n");
+    print("    /about         - About page (MainLayoutTemplate)\n");
+    print("    /admin         - Admin dashboard (AdminSectionTemplate)\n");
+    print("    /admin/users   - User management (AdminSectionTemplate)\n");
+    print("    /styles/main.css  - Main stylesheet (FastResource)\n");
+    print("    /styles/admin.css - Admin stylesheet (FastResource)\n");
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("\nPress Ctrl+C to stop the server\n\n");
+    
+    try {
+        var application = new WebApplication(port);
+        
+        // Enable compression
+        application.use_compression();
+        
+        // Add Spry module for component actions
+        application.add_module<SpryModule>();
+        
+        // Register stores as singletons
+        application.add_singleton<AppState>();
+        application.add_singleton<UserStore>();
+        
+        // Register templates with route prefixes
+        // MainLayoutTemplate applies to ALL routes (empty prefix)
+        application.add_transient<MainLayoutTemplate>()
+            .as<PageTemplate>()
+            .with_metadata(new TemplateRoutePrefix(""));
+        
+        // AdminSectionTemplate applies to /admin/* routes
+        application.add_transient<AdminSectionTemplate>()
+            .as<PageTemplate>()
+            .with_metadata(new TemplateRoutePrefix("/admin"));
+        
+        // Register page components as endpoints
+        application.add_transient<HomePage>();
+        application.add_endpoint<HomePage>(new EndpointRoute(HomePage.ROUTE));
+        
+        application.add_transient<AboutPage>();
+        application.add_endpoint<AboutPage>(new EndpointRoute(AboutPage.ROUTE));
+        
+        application.add_transient<AdminDashboardPage>();
+        application.add_endpoint<AdminDashboardPage>(new EndpointRoute(AdminDashboardPage.ROUTE));
+        
+        application.add_transient<AdminUsersPage>();
+        application.add_endpoint<AdminUsersPage>(new EndpointRoute(AdminUsersPage.ROUTE));
+        
+        // Register child components
+        application.add_transient<UserItemComponent>();
+        
+        // Register CSS as FastResources
+        application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/main.css"), () => {
+            try {
+                return new FastResource.from_string(MAIN_CSS)
+                    .with_content_type("text/css; charset=utf-8")
+                    .with_default_compressors();
+            } catch (Error e) {
+                error("Failed to create main CSS resource: %s", e.message);
+            }
+        });
+        
+        application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/admin.css"), () => {
+            try {
+                return new FastResource.from_string(ADMIN_CSS)
+                    .with_content_type("text/css; charset=utf-8")
+                    .with_default_compressors();
+            } catch (Error e) {
+                error("Failed to create admin CSS resource: %s", e.message);
+            }
+        });
+        
+        application.run();
+        
+    } catch (Error e) {
+        printerr("Error: %s\n", e.message);
+        Process.exit(1);
+    }
+}

+ 8 - 1
examples/meson.build

@@ -12,10 +12,17 @@ executable('todo-component',
     install: false
 )
 
-# TodoComponent Example - demonstrates Spry.Component for a complete todo list CRUD
+# SimpleExample - demonstrates basic Spry.Component usage with outlets
 executable('simple-example',
     'SimpleExample.vala',
     dependencies: [spry_dep, astralis_dep, invercargill_dep, inversion_dep],
     install: false
 )
 
+# TemplateExample - demonstrates PageComponent and PageTemplate for template-based pages
+executable('template-example',
+    'TemplateExample.vala',
+    dependencies: [spry_dep, astralis_dep, invercargill_dep, inversion_dep],
+    install: false
+)
+

+ 0 - 5
src/Component.vala

@@ -17,9 +17,6 @@ namespace Spry {
         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
         }
@@ -170,8 +167,6 @@ namespace Spry {
                 final_instance.body.append_nodes(globals);
             }
 
-            // Run any mangling needed
-            mangle(final_instance);
             return final_instance;
         }
 

+ 50 - 0
src/PageComponent.vala

@@ -0,0 +1,50 @@
+using Astralis;
+using Inversion;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Spry {
+
+    public abstract class PageComponent : Component, Endpoint {
+
+        private Scope scope = inject<Scope>();
+
+        public async Astralis.HttpResult handle_request (HttpContext http_context, RouteContext route_context) throws Error {
+            // Get templates as renderables in order of lowest to highest "rank"
+            var renderables = scope.get_registrations(typeof(PageTemplate))
+                .select_many<Pair<Registration, TemplateRoutePrefix>>(r => r.get_metadata<TemplateRoutePrefix>().select_pairs<Registration, TemplateRoutePrefix>(p => r, p => p))
+                .debug_trace("Pre-filter")
+                .where(p => p.value2.matches_route(route_context))
+                .debug_trace("Post-filter")
+                .order_by<uint>(p => p.value2.rank)
+                .attempt_select<Object>(p => scope.resolve_registration (p.value1))
+                .cast<Renderable>()
+                .to_series();
+
+            // Finally, add this page
+            renderables.add(this);
+
+            MarkupDocument result = null;
+            foreach (var renderable in renderables) {
+                var document = yield renderable.to_document();
+                if(result == null){
+                    result = document;
+                    continue;
+                }
+
+                var content_added = false;
+                foreach(var outlet in result.select("//spry-template-outlet")) {
+                    outlet.replace_with_nodes(document.body.children);
+                    content_added = true;
+                }
+                if(content_added && document.head != null) {
+                    result.head.append_nodes(document.head.children);
+                }
+            }
+
+            return result.to_result(get_status());
+        }
+
+    }
+
+}

+ 32 - 0
src/PageTemplate.vala

@@ -0,0 +1,32 @@
+using Astralis;
+using Inversion;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Spry {
+
+    public abstract class PageTemplate : Component {
+
+    }
+
+    public class TemplateRoutePrefix : Object {
+
+        public uint rank { get; private set; }
+        public string prefix { get; private set; }
+        public ReadOnlyAddressable<string> prefix_segments { get; private set; }
+
+        public TemplateRoutePrefix(string prefix) {
+            this.prefix = prefix;
+            prefix_segments = Wrap.array<string>(prefix.split("/")).where(s => s.length != 0).to_immutable_buffer();
+            rank = prefix_segments.length;
+        }
+
+        public bool matches_route(RouteContext context) {
+            return context.matched_route.route_segments
+                .pair_up<string>(prefix_segments)
+                .all(p => !p.value2_is_set || (p.value1_is_set && p.value1 == p.value2));
+        }
+
+    }
+
+}

+ 2 - 0
src/meson.build

@@ -1,6 +1,8 @@
 sources = files(
     'Spry.vala',
     'Component.vala',
+    'PageComponent.vala',
+    'PageTemplate.vala',
     'ComponentFactory.vala',
     'Renderable.vala',
     'ComponentEndpoint.vala',