فهرست منبع

feat(tools): add spry-mkcomponent tool and refactor expression API

Add spry-mkcomponent code generation tool for creating Spry components
with associated templates. Includes documentation page and navigation
updates.

Refactor authentication and database queries to use expression objects
directly instead of stringified expressions. This improves type safety
and API ergonomics by removing the need for `.to_expression_string()`
calls and `new NativeElement<T>()` constructor syntax.

Additional changes:
- Add `set_authorisation_token()` helper to ResponseState with proper
  cookie attributes (Secure, HttpOnly, SameSite)
- Fix null check in AuthorisationPipelineComponent for missing headers
- Defer seed_initial_data until after main loop starts via Idle.add()
- Update UserProjection to use expr() for all SQL fragments
Billy Barrow 1 ماه پیش
والد
کامیت
dde48683ae

+ 2 - 0
demo/Components/NavSidebarComponent.vala

@@ -28,6 +28,7 @@ public class NavSidebarComponent : Component {
                     <li><a href="/components/actions" class="nav-item" sid="components-actions">Actions</a></li>
                     <li><a href="/components/outlets" class="nav-item" sid="components-outlets">Outlets</a></li>
                     <li><a href="/components/continuations" class="nav-item" sid="components-continuations">Continuations</a></li>
+                    <li><a href="/components/spry-mkcomponent" class="nav-item" sid="components-spry-mkcomponent">Using spry-mkcomponent</a></li>
                 </ul>
             </div>
             
@@ -76,6 +77,7 @@ public class NavSidebarComponent : Component {
             case "/components/actions": return "components-actions";
             case "/components/outlets": return "components-outlets";
             case "/components/continuations": return "components-continuations";
+            case "/components/spry-mkcomponent": return "components-spry-mkcomponent";
             case "/page-components/overview": return "page-components-overview";
             case "/page-components/templates": return "page-components-templates";
             case "/static-resources/overview": return "static-resources-overview";

+ 3 - 0
demo/Main.vala

@@ -60,6 +60,9 @@ void main(string[] args) {
         application.add_transient<ComponentsContinuationsPage>();
         application.add_endpoint<ComponentsContinuationsPage>(new EndpointRoute("/components/continuations"));
         
+        application.add_transient<ComponentsMkcomponentPage>();
+        application.add_endpoint<ComponentsMkcomponentPage>(new EndpointRoute("/components/spry-mkcomponent"));
+        
         // Page Components documentation pages
         application.add_transient<PageComponentsOverviewPage>();
         application.add_endpoint<PageComponentsOverviewPage>(new EndpointRoute("/page-components/overview"));

+ 387 - 0
demo/Pages/ComponentsMkcomponentPage.vala

@@ -0,0 +1,387 @@
+using Spry;
+using Inversion;
+
+/**
+ * ComponentsMkcomponentPage - Documentation for the spry-mkcomponent tool
+ * 
+ * This page explains how to use spry-mkcomponent to inject HTML markup
+ * into Vala component files.
+ */
+public class ComponentsMkcomponentPage : PageComponent {
+    
+    public const string ROUTE = "/components/spry-mkcomponent";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>spry-mkcomponent Tool</h1>
+            
+            <section class="doc-section">
+                <h2>What is spry-mkcomponent?</h2>
+                <p>
+                    <code>spry-mkcomponent</code> is a command-line tool that processes Vala component
+                    files by injecting HTML markup into the <code>markup</code> property getter. This
+                    allows you to maintain your HTML templates in separate files while keeping your
+                    Vala code clean and focused on logic.
+                </p>
+                <p>
+                    The tool enables a cleaner separation of concerns:
+                </p>
+                <ul>
+                    <li><strong>Component logic</strong> - Vala code handling actions, state, and behavior</li>
+                    <li><strong>HTML templates</strong> - Separate .html files with your markup</li>
+                </ul>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Command-Line Usage</h2>
+                
+                <spry-component name="CodeBlockComponent" sid="usage-code"/>
+                
+                <h3>Options</h3>
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Option</th>
+                            <th>Description</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td><code>-o, --output FILE</code></td>
+                            <td>Output file path (default: input filename in build directory)</td>
+                        </tr>
+                        <tr>
+                            <td><code>-h, --html FILE</code></td>
+                            <td>HTML template file (default: <input_vala>.html)</td>
+                        </tr>
+                        <tr>
+                            <td><code>-v, --version</code></td>
+                            <td>Show version information</td>
+                        </tr>
+                        <tr>
+                            <td><code>--help</code></td>
+                            <td>Show help message</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>How It Works</h2>
+                <p>
+                    The tool takes two input files and produces a single output file with the
+                    HTML template embedded in the markup property:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="workflow-diagram"/>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Key Concept:</strong> The tool looks for an empty <code>markup</code>
+                        property getter and injects the HTML content. If the getter already has content,
+                        the file is passed through unchanged.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Example Transformation</h2>
+                <p>
+                    Here's a complete example showing how the tool transforms your component files:
+                </p>
+                
+                <h3>Input Vala File (<code>UserContentComponent.vala</code>)</h3>
+                <spry-component name="CodeBlockComponent" sid="input-vala"/>
+                
+                <h3>Input HTML File (<code>UserContentComponent.vala.html</code>)</h3>
+                <spry-component name="CodeBlockComponent" sid="input-html"/>
+                
+                <h3>Output Vala File (<code>UserContentComponent.vala</code>)</h3>
+                <spry-component name="CodeBlockComponent" sid="output-vala"/>
+                
+                <p>
+                    Notice how the empty <code>markup</code> getter now returns the HTML content
+                    as a verbatim string literal. The <code>sid</code> attributes in your HTML
+                    are preserved, allowing you to access elements via <code>this["sid"]</code>
+                    in your Vala code.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Integrating with Meson</h2>
+                <p>
+                    The recommended way to use spry-mkcomponent is with meson's build system.
+                    You can use either <code>generator()</code> for processing multiple files
+                    or <code>custom_target()</code> for single files with custom options.
+                </p>
+                
+                <h3>Using generator() for Multiple Files</h3>
+                <spry-component name="CodeBlockComponent" sid="meson-generator"/>
+                
+                <p>
+                    The <code>generator()</code> function is ideal when you have multiple component
+                    files that all follow the same pattern (HTML file named
+                    <code><component>.vala.html</code>).
+                </p>
+                
+                <h3>Using custom_target() for Single Files</h3>
+                <spry-component name="CodeBlockComponent" sid="meson-custom-target"/>
+                
+                <p>
+                    Use <code>custom_target()</code> when you need more control over file paths
+                    or want to specify a custom HTML template location.
+                </p>
+                
+                <h3>Complete meson.build Example</h3>
+                <spry-component name="CodeBlockComponent" sid="meson-complete"/>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Tip:</strong> The <code>@INPUT@</code> and <code>@OUTPUT@</code>
+                        placeholders are automatically replaced by meson with the actual file paths.
+                        The <code>@BASENAME@</code> placeholder is replaced with the input filename
+                        without extension.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>HTML Template Guidelines</h2>
+                <p>
+                    When creating HTML templates for use with spry-mkcomponent, follow these guidelines:
+                </p>
+                
+                <ul>
+                    <li>
+                        <strong>Use <code>sid</code> attributes</strong> - Add <code>sid="name"</code>
+                        to elements you need to access from Vala code via <code>this["name"]</code>
+                    </li>
+                    <li>
+                        <strong>Keep templates alongside Vala files</strong> - Name them
+                        <code>ComponentName.vala.html</code> for automatic discovery
+                    </li>
+                    <li>
+                        <strong>Use HTMX attributes</strong> - Add <code>hx-*</code> attributes for
+                        interactivity without writing JavaScript
+                    </li>
+                    <li>
+                        <strong>Avoid complex logic</strong> - Keep templates focused on structure;
+                        use Vala for conditional rendering
+                    </li>
+                </ul>
+                
+                <spry-component name="CodeBlockComponent" sid="html-guidelines"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Workflow Integration</h2>
+                <p>
+                    A typical development workflow with spry-mkcomponent looks like this:
+                </p>
+                
+                <ol>
+                    <li><strong>Create</strong> your Vala component with an empty <code>markup</code> getter</li>
+                    <li><strong>Create</strong> an HTML template file with the same base name</li>
+                    <li><strong>Build</strong> your project - meson runs spry-mkcomponent automatically</li>
+                    <li><strong>Iterate</strong> - Edit either file; changes are picked up on rebuild</li>
+                </ol>
+                
+                <p>
+                    During development, you can run the tool manually to preview the generated output:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="manual-run"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>When to Use spry-mkcomponent</h2>
+                
+                <h3>✅ Good Use Cases</h3>
+                <ul>
+                    <li>Components with substantial HTML markup</li>
+                    <li>Teams where designers work on HTML separately from developers</li>
+                    <li>Reusing HTML templates across multiple components</li>
+                    <li>Keeping Vala files focused on logic</li>
+                </ul>
+                
+                <h3>❌ When to Skip It</h3>
+                <ul>
+                    <li>Simple components with minimal markup</li>
+                    <li>Quick prototypes where separation isn't beneficial</li>
+                    <li>Components with dynamically generated markup</li>
+                </ul>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Remember:</strong> You can always inline markup directly in your
+                        Vala files. spry-mkcomponent is opt-in - use it when the separation provides
+                        value for your project.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/components/template-syntax" class="nav-card">
+                        <h3>← Template Syntax</h3>
+                        <p>Learn about component template syntax</p>
+                    </a>
+                    <a href="/components/actions" class="nav-card">
+                        <h3>Actions →</h3>
+                        <p>Handle user interactions in components</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        var usage_code = get_component_child<CodeBlockComponent>("usage-code");
+        usage_code.language = "Bash";
+        usage_code.code = "spry-mkcomponent [OPTIONS] <INPUT_VALA_FILE>\n\n" +
+            "# Examples:\n" +
+            "spry-mkcomponent Components/UserComponent.vala\n" +
+            "spry-mkcomponent -o build/UserComponent.vala Components/UserComponent.vala\n" +
+            "spry-mkcomponent --html Templates/User.html Components/UserComponent.vala";
+        
+        var workflow_diagram = get_component_child<CodeBlockComponent>("workflow-diagram");
+        workflow_diagram.language = "Text";
+        workflow_diagram.code = "┌─────────────────────────────┐     ┌─────────────────────────────┐\n" +
+            "│   Component.vala           │     │   Component.vala.html       │\n" +
+            "│   (empty markup getter)    │     │   (HTML template)           │\n" +
+            "└─────────────┬───────────────┘     └─────────────┬───────────────┘\n" +
+            "              │                                   │\n" +
+            "              └───────────────┬───────────────────┘\n" +
+            "                              │\n" +
+            "                              ▼\n" +
+            "                    ┌─────────────────┐\n" +
+            "                    │ spry-mkcomponent │\n" +
+            "                    └────────┬────────┘\n" +
+            "                             │\n" +
+            "                             ▼\n" +
+            "              ┌─────────────────────────────┐\n" +
+            "              │   Component.vala            │\n" +
+            "              │   (with generated getter)   │\n" +
+            "              └─────────────────────────────┘";
+        
+        var input_vala = get_component_child<CodeBlockComponent>("input-vala");
+        input_vala.language = "Vala";
+        input_vala.code = "class UserContentComponent : Component {\n" +
+            "    public override string markup { get; }\n\n" +
+            "    public async override void handle_action(string action) throws Error {\n" +
+            "        this[\"message\"].text_content = \"Hello, World!\";\n" +
+            "    }\n" +
+            "}";
+        
+        var input_html = get_component_child<CodeBlockComponent>("input-html");
+        input_html.language = "HTML";
+        input_html.code = "<p>You said: <strong sid=\"message\"></strong></p>";
+        
+        var output_vala = get_component_child<CodeBlockComponent>("output-vala");
+        output_vala.language = "Vala";
+        output_vala.code = "class UserContentComponent : Component {\n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"<p>You said: <strong sid=\"message\"></strong></p>\"\"\";\n" +
+            "    }}\n\n" +
+            "    public async override void handle_action(string action) throws Error {\n" +
+            "        this[\"message\"].text_content = \"Hello, World!\";\n" +
+            "    }\n" +
+            "}";
+        
+        var meson_generator = get_component_child<CodeBlockComponent>("meson-generator");
+        meson_generator.language = "Meson";
+        meson_generator.code = "# Find the tool\n" +
+            "spry_mkcomponent = find_program('spry-mkcomponent')\n\n" +
+            "# Define the generator once\n" +
+            "spry_mkcomponent_gen = generator(spry_mkcomponent,\n" +
+            "    output: '@BASENAME@.vala',\n" +
+            "    arguments: ['@INPUT@', '@OUTPUT@']\n" +
+            ")\n\n" +
+            "# Process multiple component files\n" +
+            "generated_components = spry_mkcomponent_gen.process(files(\n" +
+            "    'Components/UserContentComponent.vala',\n" +
+            "    'Components/LoginFormComponent.vala',\n" +
+            "    'Components/NavSidebarComponent.vala',\n" +
+            "))\n\n" +
+            "# Use in executable\n" +
+            "executable('myapp',\n" +
+            "    sources: [other_sources, generated_components],\n" +
+            "    dependencies: [spry_dep]\n" +
+            ")";
+        
+        var meson_custom_target = get_component_child<CodeBlockComponent>("meson-custom-target");
+        meson_custom_target.language = "Meson";
+        meson_custom_target.code = "# Simple case - HTML file is ComponentName.vala.html\n" +
+            "user_content_component = custom_target('user-content-component',\n" +
+            "    input: 'Components/UserContentComponent.vala',\n" +
+            "    output: 'UserContentComponent.vala',\n" +
+            "    command: [spry_mkcomponent, '@INPUT@', '@OUTPUT@']\n" +
+            ")\n\n" +
+            "# With explicit HTML file path\n" +
+            "login_form_component = custom_target('login-form-component',\n" +
+            "    input: 'Components/LoginFormComponent.vala',\n" +
+            "    output: 'LoginFormComponent.vala',\n" +
+            "    command: [spry_mkcomponent, '@INPUT@', '@OUTPUT@',\n" +
+            "              '--html', 'Templates/LoginForm.html']\n" +
+            ")";
+        
+        var meson_complete = get_component_child<CodeBlockComponent>("meson-complete");
+        meson_complete.language = "Meson";
+        meson_complete.code = "# Find the spry-mkcomponent tool\n" +
+            "spry_mkcomponent = find_program('spry-mkcomponent')\n\n" +
+            "# Define generator for components\n" +
+            "component_gen = generator(spry_mkcomponent,\n" +
+            "    output: '@BASENAME@.vala',\n" +
+            "    arguments: ['@INPUT@', '@OUTPUT@']\n" +
+            ")\n\n" +
+            "# Process all component files with HTML templates\n" +
+            "generated_components = component_gen.process(files(\n" +
+            "    'Components/HeaderComponent.vala',\n" +
+            "    'Components/FooterComponent.vala',\n" +
+            "    'Components/SidebarComponent.vala',\n" +
+            "))\n\n" +
+            "# Other source files (no HTML templates)\n" +
+            "other_sources = files(\n" +
+            "    'Main.vala',\n" +
+            "    'Components/SimpleComponent.vala',  # Inline markup\n" +
+            ")\n\n" +
+            "# Build executable with generated components\n" +
+            "executable('myapp',\n" +
+            "    sources: [other_sources, generated_components],\n" +
+            "    dependencies: [spry_dep],\n" +
+            "    vala_args: ['--vapidir', spry_vapi_dir]\n" +
+            ")";
+        
+        var html_guidelines = get_component_child<CodeBlockComponent>("html-guidelines");
+        html_guidelines.language = "HTML";
+        html_guidelines.code = "<!-- Good: Use sid attributes for elements you need to access -->\n" +
+            "<div class=\"user-card\">\n" +
+            "    <h2 sid=\"username\"></h2>\n" +
+            "    <p sid=\"email\"></p>\n" +
+            "    <button sid=\"delete-btn\" hx-post=\"/delete\" hx-target=\"closest .user-card\">\n" +
+            "        Delete\n" +
+            "    </button>\n" +
+            "</div>\n\n" +
+            "<!-- Good: Keep it simple and focused on structure -->\n" +
+            "<form sid=\"login-form\" hx-post=\"/login\">\n" +
+            "    <input type=\"text\" name=\"username\" sid=\"username-input\"/>\n" +
+            "    <input type=\"password\" name=\"password\" sid=\"password-input\"/>\n" +
+            "    <button type=\"submit\">Login</button>\n" +
+            "</form>";
+        
+        var manual_run = get_component_child<CodeBlockComponent>("manual-run");
+        manual_run.language = "Bash";
+        manual_run.code = "# Run manually to see generated output\n" +
+            "spry-mkcomponent Components/MyComponent.vala -o /tmp/output.vala\n\n" +
+            "# View the result\n" +
+            "cat /tmp/output.vala\n\n" +
+            "# Check if the tool would modify a file (compare with diff)\n" +
+            "spry-mkcomponent Components/MyComponent.vala -o /tmp/test.vala && \\\n" +
+            "    diff Components/MyComponent.vala /tmp/test.vala";
+    }
+}

+ 1 - 0
demo/meson.build

@@ -13,6 +13,7 @@ website_sources = files(
     'Pages/PageComponentsOverviewPage.vala',
     'Pages/PageTemplatesPage.vala',
     'Pages/StaticResourcesOverviewPage.vala',
+    'Pages/ComponentsMkcomponentPage.vala',
     'Pages/StaticResourcesMkssrPage.vala',
     'Components/CodeBlockComponent.vala',
     'Components/DemoHostComponent.vala',

+ 10 - 0
examples/UserContentComponent.vala

@@ -0,0 +1,10 @@
+class UserContentComponent : Component {
+    public override string markup { get; }
+
+    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;
+    }
+}

+ 1 - 0
examples/UserContentComponent.vala.html

@@ -0,0 +1 @@
+<p>You said: <strong sid="message"></strong> <em>(via <span sid="action"></span>)</em></p>

+ 8 - 3
examples/UsersExample.vala

@@ -1003,9 +1003,6 @@ private async void start_application(int port) throws Error {
     application.add_scoped<AuthorisationPipelineComponent>()
         .as<Astralis.PipelineComponent>();
     
-    // Seed initial data (admin user, test user)
-    seed_initial_data.begin(application.container);
-    
     // Register template with route prefix
     // MainLayoutTemplate applies to ALL routes (empty prefix)
     var spry_cfg = application.configure_with<SpryConfigurator>();
@@ -1051,6 +1048,14 @@ private async void start_application(int port) throws Error {
     });
     
     print("Starting web server on port %d...\n\n", port);
+    
+    // Seed initial data after migrations have run (migrations execute during application.run())
+    // Use Idle.add() to ensure this runs after the main loop starts
+    GLib.Idle.add(() => {
+        seed_initial_data.begin(application.container);
+        return false; // Don't repeat
+    });
+    
     application.run();
 }
 

+ 251 - 0
plans/spry-mkcomponent-design.md

@@ -0,0 +1,251 @@
+# spry-mkcomponent Tool Design
+
+## Overview
+
+A new tool called `spry-mkcomponent` that processes Vala component files by injecting HTML markup into the `markup` property getter. This separates the HTML template from the Vala code, making components easier to maintain.
+
+## Tool Behavior
+
+### Input/Output
+
+```
+Input:  UserContentComponent.vala      (Vala source with empty markup getter)
+        UserContentComponent.vala.html (HTML template - auto-discovered)
+
+Output: UserContentComponent.vala      (Generated Vala with markup getter populated)
+```
+
+### Command Line Interface
+
+```bash
+spry-mkcomponent [OPTIONS] <INPUT_VALA_FILE>
+
+Options:
+  -o, --output FILE         Output file path (default: input filename in build directory)
+  -h, --html FILE           HTML template file (default: <input_vala>.html)
+  -v, --version             Show version information
+  --help                    Show help message
+```
+
+### Processing Logic
+
+1. Read the input `.vala` file
+2. Locate the HTML template file:
+   - If `-h/--html` is specified, use that path
+   - Otherwise, append `.html` to the input vala file path
+3. Parse the Vala file to find the `markup` property with an empty getter:
+   - Pattern: `public override string markup { get; }`
+   - The property MUST have exactly this form (empty getter)
+4. Read the HTML template content
+5. Generate the output by replacing the empty getter with a getter that returns the HTML as a verbatim string
+6. Write the output file
+
+### Error Conditions
+
+| Condition | Error Message |
+|-----------|---------------|
+| Input file not found | `Error: Input file 'PATH' does not exist` |
+| HTML file not found | `Error: HTML template file 'PATH' does not exist` |
+| No markup property found | `Error: No 'public override string markup' property found in 'PATH'` |
+| Markup property has non-empty getter | `Error: The markup property in 'PATH' already has a getter defined. Expected empty getter: { get; }` |
+
+## Example Transformation
+
+### Input: [`UserContentComponent.vala`](examples/UserContentComponent.vala)
+
+```vala
+class UserContentComponent : Component {
+    public override string markup { get; }
+
+    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;
+    }
+}
+```
+
+### Input: [`UserContentComponent.vala.html`](examples/UserContentComponent.vala.html)
+
+```html
+<p>You said: <strong sid="message"></strong> <em>(via <span sid="action"></span>)</em></p>
+```
+
+### Output: `UserContentComponent.vala` (generated)
+
+```vala
+class UserContentComponent : Component {
+    public override string markup { get {
+        return """
+        <p>You said: <strong sid="message"></strong> <em>(via <span sid="action"></span>)</em></p>
+        """;
+    }}
+
+    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;
+    }
+}
+```
+
+## Meson Integration: generator() vs custom_target()
+
+### generator()
+
+**Best for:** Multiple files with the same transformation pattern.
+
+**Pros:**
+- Reusable - define once, use for many files
+- Cleaner syntax when processing multiple components
+- Meson automatically handles output file naming
+
+**Cons:**
+- Less flexibility per-file (same command arguments for all files)
+- Output filename is automatically determined
+
+**Example Usage:**
+
+```meson
+# Define the generator once
+spry_mkcomponent_gen = generator(spry_mkcomponent,
+    output: '@BASENAME@.vala',
+    arguments: ['@INPUT@', '@OUTPUT@']
+)
+
+# Process multiple component files
+generated_components = spry_mkcomponent_gen.process(files(
+    'Components/UserContentComponent.vala',
+    'Components/LoginFormComponent.vala',
+    'Components/NavSidebarComponent.vala',
+))
+
+# Use in executable
+executable('myapp',
+    sources: [other_sources, generated_components],
+    dependencies: [spry_dep]
+)
+```
+
+### custom_target()
+
+**Best for:** Single files or when you need per-file customization.
+
+**Pros:**
+- Full control over command arguments per file
+- Can specify custom output filename
+- Better for complex dependencies
+
+**Cons:**
+- More verbose for multiple files
+- Each file needs its own custom_target declaration
+
+**Example Usage:**
+
+```meson
+# Single component with custom HTML path
+user_content_component = custom_target('user-content-component',
+    input: 'Components/UserContentComponent.vala',
+    output: 'UserContentComponent.vala',
+    command: [spry_mkcomponent, '@INPUT@', '@OUTPUT@']
+)
+
+# With explicit HTML file
+login_form_component = custom_target('login-form-component',
+    input: 'Components/LoginFormComponent.vala',
+    output: 'LoginFormComponent.vala',
+    command: [spry_mkcomponent, '@INPUT@', '@OUTPUT@', '--html', 'Templates/LoginForm.html']
+)
+
+executable('myapp',
+    sources: [user_content_component, login_form_component, other_sources],
+    dependencies: [spry_dep]
+)
+```
+
+### Recommendation
+
+For this use case, I recommend **`generator()`** as the primary pattern because:
+
+1. Components typically follow the same convention (`.vala` + `.vala.html`)
+2. Most use cases will process multiple component files
+3. The pattern matches how `spry-mkssr` is used in the demo build
+
+However, the tool should support both patterns by:
+- Accepting input/output as positional arguments (for generator)
+- Supporting `--html` flag for explicit HTML file paths (for custom_target when needed)
+
+## Implementation Architecture
+
+```mermaid
+flowchart TD
+    A[Command Line Args] --> B[Argument Parser]
+    B --> C[File Discovery]
+    C --> D{HTML file specified?}
+    D -->|Yes| E[Use specified HTML path]
+    D -->|No| F[Append .html to vala path]
+    E --> G[Read Input Files]
+    F --> G
+    G --> H[Parse Vala Source]
+    H --> I{Find markup property}
+    I -->|Found| J{Is getter empty?}
+    I -->|Not Found| K[Error: No markup property]
+    J -->|Yes| L[Generate Output]
+    J -->|No| M[Error: Getter not empty]
+    L --> N[Write Output File]
+    N --> O[Success]
+```
+
+## File Structure
+
+```
+tools/
+├── meson.build
+└── spry-mkcomponent/
+    ├── meson.build
+    └── spry-mkcomponent.vala
+```
+
+## Dependencies
+
+The tool will need:
+- `glib-2.0` - Core GLib functionality
+- `gobject-2.0` - GObject type system
+- `gio-2.0` - File I/O operations
+
+Note: Unlike `spry-mkssr`, this tool does NOT need `invercargill`, `astralis`, `inversion`, or compression libraries since it only does text processing.
+
+## Implementation Tasks
+
+1. Create `tools/spry-mkcomponent/` directory structure
+2. Implement `spry-mkcomponent.vala` with:
+   - Command-line argument parsing using `OptionContext`
+   - File reading/writing using `gio`
+   - Vala source parsing to find `markup` property
+   - HTML template injection
+   - Error handling with descriptive messages
+3. Create `tools/spry-mkcomponent/meson.build`
+4. Update `tools/meson.build` to include the new subdirectory
+5. Add example usage documentation
+
+## Parsing Strategy
+
+The Vala source parsing will use a simple regex-based approach:
+
+1. Search for pattern: `public\s+override\s+string\s+markup\s*\{\s*get\s*;\s*\}`
+2. Replace with the generated getter containing the HTML
+
+This is sufficient because:
+- The pattern is well-defined and consistent
+- Full Vala parsing would add unnecessary complexity
+- The tool validates that the property exists and has an empty getter
+
+### Edge Cases to Handle
+
+- Multiple spaces/tabs between keywords
+- Different indentation styles
+- Windows vs Unix line endings (normalize to Unix)
+- Preserve original indentation in output
+- Escape triple quotes in HTML content (rare but possible)

+ 2 - 3
src/Authentication/Components/LoginFormComponent.vala

@@ -189,13 +189,12 @@ namespace Spry.Authentication.Components {
             }
 
             stdout.printf("LOGIN DEBUG: Authentication successful for user: %s\n",
-                token.user_identifier != null ? token.user_identifier.to_string() : "unknown");
+                token.user_identifier != null ? token.user_identifier.stringify() : "unknown");
 
             // Set authorisation cookie using the refined AuthorisationService
             // Note: We use response.add_header to set the Set-Cookie header directly
             stdout.printf("LOGIN DEBUG: Setting authorisation cookie...\n");
-            var token_str = token.cryptographic_token.to_base64();
-            response.add_header("Set-Cookie", @"_spry-authorisation=$token_str; Secure");
+            response.set_authorisation_token(token);
             stdout.printf("LOGIN DEBUG: Authorisation cookie set\n");
 
             // Set up HTMX redirect using ResponseState

+ 2 - 2
src/Authentication/Components/UserDetailsComponent.vala

@@ -111,7 +111,7 @@ namespace Spry.Authentication.Components {
         public override string markup { get {
             return """
             <details sid="user-details"
-                     id-expr="'user-' + this.user_id"
+                     id-expr='"user-" + this.user_id'
                      hx-swap="outerHTML">
                 <!-- Summary: Always visible, shows key info -->
                 <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; cursor: pointer; list-style: none;">
@@ -132,7 +132,7 @@ namespace Spry.Authentication.Components {
                         <tbody>
                             <tr>
                                 <td style="padding: 0.5rem 0; font-weight: 500; width: 150px; vertical-align: top;">User ID</td>
-                                <td style="padding: 0.5rem 0;"><code content-expr="this.user_id.to_string()" style="font-size: 0.85rem; background: #f5f5f5; padding: 0.125rem 0.25rem; border-radius: 3px;"></code></td>
+                                <td style="padding: 0.5rem 0;"><code content-expr="stringify(this.user_id)" style="font-size: 0.85rem; background: #f5f5f5; padding: 0.125rem 0.25rem; border-radius: 3px;"></code></td>
                             </tr>
                             <tr>
                                 <td style="padding: 0.5rem 0; font-weight: 500;">Username</td>

+ 2 - 2
src/Authentication/UserIdentityProvider.vala

@@ -12,12 +12,12 @@ namespace Spry.Authentication {
 
         public async Authorisation.Identity? get_identity_by_identifier (Element id) throws Error {
             return yield db.query<UserProjection>()
-                .where(expr("id == $0", id).to_expression_string())
+                .where(expr("id == $0", id))
                 .first_async();
         }
         public async Authorisation.Identity? get_identity_by_username (string username) throws Error {
             return yield db.query<UserProjection>()
-                .where(expr("username == $0", new NativeElement<string>(username)).to_expression_string())
+                .where(expr("username == $0", elem<string>(username)))
                 .first_async();
         }
         

+ 13 - 12
src/Authentication/UserProjection.vala

@@ -2,6 +2,7 @@ using InvercargillSql.Orm.Projections;
 using Invercargill.DataStructures;
 using Spry.Authorisation;
 using Invercargill;
+using Invercargill.Expressions;
 
 namespace Spry.Authentication {
 
@@ -36,18 +37,18 @@ namespace Spry.Authentication {
 
         public static void projection_mapping(ProjectionBuilder<UserProjection> cfg) throws Error {
             cfg.source<UserEntity>("u")
-                .select<int64?>("id", "u.id", (o, v) => o.id = v)
-                .select<string>("username", "u.username", (o, v) => o._username = v)
-                .select<string>("email", "u.email", (o, v) => o.email = v)
-                .select<string>("forename", "u.forename", (o, v) => o.forename = v)
-                .select<string>("surname", "u.surname", (o, v) => o.surname = v)
-                .select<string>("password_hash", "u.password_hash", (o, v) => o.password_hash = v)
-                .select<DateTime>("date_of_birth", "u.date_of_birth", (o, v) => o.date_of_birth = v)
-                .select<DateTime>("created", "u.created", (o, v) => o.created = v)
-                .select<DateTime>("modified", "u.modified", (o, v) => o.modified = v)
-                .select<bool>("enabled", "u.enabled", (o, v) => o.enabled = v)
-                .join<UserPermissionEntity>("p", "p.user_id == u.id")
-                .select_many<string>("permissions", "p.permission", (o, v) => o._permissions = v.to_immutable_buffer());
+                .select<int64?>("id", expr("u.id"), (o, v) => o.id = v)
+                .select<string>("username", expr("u.username"), (o, v) => o._username = v)
+                .select<string>("email", expr("u.email"), (o, v) => o.email = v)
+                .select<string>("forename", expr("u.forename"), (o, v) => o.forename = v)
+                .select<string>("surname", expr("u.surname"), (o, v) => o.surname = v)
+                .select<string>("password_hash", expr("u.password_hash"), (o, v) => o.password_hash = v)
+                .select<DateTime>("date_of_birth", expr("u.date_of_birth"), (o, v) => o.date_of_birth = v)
+                .select<DateTime>("created", expr("u.created"), (o, v) => o.created = v)
+                .select<DateTime>("modified", expr("u.modified"), (o, v) => o.modified = v)
+                .select<bool>("enabled", expr("u.enabled"), (o, v) => o.enabled = v)
+                .join<UserPermissionEntity>("p", expr("p.user_id == u.id"))
+                .select_many<string>("permissions", expr("p.permission"), (o, v) => o._permissions = v.to_immutable_buffer());
         }
 
     }

+ 7 - 8
src/Authentication/UserService.vala

@@ -14,9 +14,8 @@ namespace Spry.Authentication {
 
 
         public async AuthorisationToken? authenticate_user(string username, string password) throws Error {
-            print(expr("username == $0", new NativeElement<string>(username)).to_expression_string());
             var user = yield db.query<UserProjection>()
-                .where(expr("username == $0", new NativeElement<string>(username)).to_expression_string())
+                .where(expr("username == $0", elem(username)))
                 .first_async();
 
             if(!Sodium.PasswordHashing.check(user.password_hash, password)){
@@ -45,7 +44,7 @@ namespace Spry.Authentication {
 
         public async void set_password(int64 user_id, string password) throws Error {
             var user = yield db.query<UserEntity>()
-                .where(expr("id == $0", new NativeElement<int64?>(user_id)).to_expression_string())
+                .where(expr("id == $0", elem<int64?>(user_id)))
                 .first_async();
 
             user.password_hash = Sodium.PasswordHashing.hash(password);
@@ -55,7 +54,7 @@ namespace Spry.Authentication {
 
         public async UserEntity alter_user(int64 user_id, string username, string email, string forename, string surname, DateTime date_of_birth, bool enabled) throws Error {
             var user = yield db.query<UserEntity>()
-                .where(expr("id == $0", new NativeElement<int64?>(user_id)).to_expression_string())
+                .where(expr("id == $0", elem<int64?>(user_id)))
                 .first_async();
 
             user.username = username;
@@ -72,7 +71,7 @@ namespace Spry.Authentication {
 
         public async void set_user_enabled(int64 user_id, bool enabled) throws Error {
             var user = yield db.query<UserEntity>()
-                .where(expr("id == $0", new NativeElement<int64?>(user_id)).to_expression_string())
+                .where(expr("id == $0", elem<int64?>(user_id)))
                 .first_async();
 
             user.modified = new DateTime.now_utc();
@@ -90,7 +89,7 @@ namespace Spry.Authentication {
 
         public async void delete_user(int64 user_id) throws Error {
             var user = yield db.query<UserEntity>()
-                .where(expr("id == $0", new NativeElement<int64?>(user_id)).to_expression_string())
+                .where(expr("id == $0", elem<int64?>(user_id)))
                 .first_async();
             db.delete<UserEntity>(user);
         }
@@ -105,7 +104,7 @@ namespace Spry.Authentication {
 
         public async void clear_user_permissions(int64 user_id) throws Error {
             var permissions = yield db.query<UserPermissionEntity>()
-                .where(expr("user_id == $0", new NativeElement<int64?>(user_id)).to_expression_string())
+                .where(expr("user_id == $0", elem<int64?>(user_id)))
                 .materialise_async();
 
             foreach (var permission in permissions) {
@@ -115,7 +114,7 @@ namespace Spry.Authentication {
 
         public async ImmutableLot<string> get_user_permissions(int64 user_id) throws Error {
             var permissions = yield db.query<UserPermissionEntity>()
-                .where(expr("user_id == $0", new NativeElement<int64?>(user_id)).to_expression_string())
+                .where(expr("user_id == $0", elem<int64?>(user_id)))
                 .materialise_async();
 
             var result = new Vector<string>();

+ 1 - 1
src/Authorisation/AuthorisationPipelineComponent.vala

@@ -12,7 +12,7 @@ namespace Spry.Authorisation {
 
             var header = http_context.request.headers.get_any_or_default("Authorization");
             AuthorisationToken token = null;
-            if(header.down().has_prefix ("bearer")) {
+            if(header != null && header.down().has_prefix ("bearer")) {
                 try {
                     token = authorisation_service.read_token (header.substring(7).chug().chomp());
                 }

+ 1 - 1
src/Component.vala

@@ -16,7 +16,7 @@ namespace Spry {
     }
 
     public abstract class Component : Object, Renderable {
-        
+
         private static Dictionary<Type, ComponentTemplate> templates;
         private static Mutex templates_lock = Mutex();
         public string context_key { get; internal set; default = Uuid.string_random(); }

+ 5 - 0
src/ResponseState.vala

@@ -87,6 +87,11 @@ namespace Spry {
         public void set_status(StatusCode status) {
             _status_code = status;
         }
+
+        public void set_authorisation_token(Authorisation.AuthorisationToken token) {
+            var token_str = token.cryptographic_token.to_base64();
+            add_header("Set-Cookie", @"$(Authorisation.COOKIE_NAME)=$token_str; Path=/; Secure; HttpOnly; SameSite=Strict");
+        }
         
         /**
          * Sets up a redirect.

+ 1 - 0
tools/meson.build

@@ -1,3 +1,4 @@
 # Spry Tools
 
 subdir('spry-mkssr')
+subdir('spry-mkcomponent')

+ 9 - 0
tools/spry-mkcomponent/meson.build

@@ -0,0 +1,9 @@
+spry_mkcomponent_sources = files(
+    'spry-mkcomponent.vala'
+)
+
+spry_mkcomponent = executable('spry-mkcomponent',
+    spry_mkcomponent_sources,
+    dependencies: [glib_dep, gobject_dep, gio_dep],
+    install: true
+)

+ 227 - 0
tools/spry-mkcomponent/spry-mkcomponent.vala

@@ -0,0 +1,227 @@
+namespace Spry.Tools {
+
+    public class Mkcomponent : Object {
+
+        private static bool show_version = false;
+        private static string? output_file = null;
+        private static string? html_file = null;
+
+        private const OptionEntry[] options = {
+            { "output", 'o', 0, OptionArg.FILENAME, ref output_file, "Output file path (alternative to positional OUTPUT_FILE)", "FILE" },
+            { "html", 'H', 0, OptionArg.FILENAME, ref html_file, "HTML template file (default: <input_vala>.html)", "FILE" },
+            { "version", 'v', 0, OptionArg.NONE, ref show_version, "Show version information", null },
+            { null }
+        };
+
+        public static int main(string[] args) {
+            try {
+                var opt_context = new OptionContext("<INPUT_VALA_FILE> [OUTPUT_FILE] - Inject HTML markup into Vala component files");
+                opt_context.set_help_enabled(true);
+                opt_context.add_main_entries(options, null);
+                opt_context.set_description(
+                    "Arguments:\n" +
+                    "  INPUT_VALA_FILE         Input Vala component file\n" +
+                    "  OUTPUT_FILE             Output Vala file (optional, default: input filename in build directory)\n" +
+                    "\n" +
+                    "The output file can be specified either as a positional argument or via -o/--output."
+                );
+                opt_context.parse(ref args);
+            } catch (OptionError e) {
+                stderr.printf("Error: %s\n", e.message);
+                stderr.printf("Run '%s --help' for more information.\n", args[0]);
+                return 1;
+            }
+
+            if (show_version) {
+                stdout.printf("spry-mkcomponent 0.1\n");
+                return 0;
+            }
+
+            // Get input file and optional output file from remaining arguments
+            string? input_file = null;
+            string? positional_output = null;
+            
+            if (args.length > 1) {
+                input_file = args[1];
+            }
+            if (args.length > 2) {
+                positional_output = args[2];
+            }
+
+            if (input_file == null) {
+                stderr.printf("Error: No input file specified.\n");
+                stderr.printf("Run '%s --help' for more information.\n", args[0]);
+                return 1;
+            }
+
+            // Determine output file: -o flag takes precedence over positional argument
+            string actual_output;
+            if (output_file != null) {
+                actual_output = output_file;
+            } else if (positional_output != null) {
+                actual_output = positional_output;
+            } else {
+                // Default: use the input filename (will be written to build directory by meson)
+                actual_output = input_file;
+            }
+
+            // Determine HTML file
+            string actual_html;
+            if (html_file != null) {
+                actual_html = html_file;
+            } else {
+                // Default: append .html to the input vala file path
+                actual_html = input_file + ".html";
+            }
+
+            try {
+                var tool = new Mkcomponent();
+                tool.process(input_file, actual_output, actual_html);
+                return 0;
+            } catch (Error e) {
+                stderr.printf("%s\n", e.message);
+                return 1;
+            }
+        }
+
+        public void process(string input_path, string output_path, string html_path) throws Error {
+            // Check if input file exists
+            var input_file = File.new_for_path(input_path);
+            if (!input_file.query_exists()) {
+                throw new IOError.NOT_FOUND(@"Error: Input file '$(input_path)' does not exist");
+            }
+
+            // Check if HTML file exists
+            var html_file_obj = File.new_for_path(html_path);
+            if (!html_file_obj.query_exists()) {
+                throw new IOError.NOT_FOUND(@"Error: HTML template file '$(html_path)' does not exist");
+            }
+
+            // Read input Vala file
+            string vala_content = read_file_content(input_path);
+
+            // Read HTML template
+            string html_content = read_file_content(html_path);
+
+            // Normalize line endings to Unix style
+            vala_content = vala_content.replace("\r\n", "\n").replace("\r", "\n");
+            html_content = html_content.replace("\r\n", "\n").replace("\r", "\n");
+
+            // First check if there's a markup property with a getter body (to give specific error)
+            Regex getter_with_body_regex;
+            try {
+                // This pattern matches a markup property with a getter that has a body
+                getter_with_body_regex = new Regex(
+                    @"public\\s+override\\s+string\\s+markup\\s*\\{\\s*get\\s*\\{",
+                    RegexCompileFlags.MULTILINE
+                );
+            } catch (RegexError e) {
+                throw new IOError.FAILED(@"Internal error: Failed to compile regex: $(e.message)");
+            }
+
+            if (getter_with_body_regex.match(vala_content)) {
+                throw new IOError.FAILED(@"Error: The markup property in '$(input_path)' already has a getter defined. Expected empty getter: { get; }");
+            }
+
+            // Now check for the empty getter pattern
+            MatchInfo match_info;
+            Regex markup_property_regex;
+            try {
+                // Pattern to match: public override string markup { get; }
+                // Allow for various whitespace patterns
+                markup_property_regex = new Regex(
+                    @"public\\s+override\\s+string\\s+markup\\s*\\{\\s*get\\s*;\\s*\\}",
+                    RegexCompileFlags.MULTILINE
+                );
+            } catch (RegexError e) {
+                throw new IOError.FAILED(@"Internal error: Failed to compile regex: $(e.message)");
+            }
+
+            if (!markup_property_regex.match(vala_content, 0, out match_info)) {
+                throw new IOError.NOT_FOUND(@"Error: No 'public override string markup' property found in '$(input_path)'");
+            }
+
+            // Escape any triple quotes in HTML content by replacing """ with \"\"\"
+            string escaped_html = html_content.replace("\"\"\"", "\\\"\\\"\\\"");
+
+            // Determine the indentation to use for the HTML content
+            // Find the line containing the markup property and extract its indentation
+            string indentation = extract_indentation(vala_content);
+
+            // Generate the replacement - we replace just the "{ get; }" part
+            // Regex to match just the getter part: { get; }
+            Regex getter_regex;
+            try {
+                getter_regex = new Regex(
+                    @"\\{\\s*get\\s*;\\s*\\}",
+                    0
+                );
+            } catch (RegexError e) {
+                throw new IOError.FAILED(@"Internal error: Failed to compile getter regex: $(e.message)");
+            }
+
+            // Build the replacement getter - HTML content placed verbatim between triple quotes
+            string replacement = @"{ get {\n$(indentation)$(indentation)return \"\"\"$(escaped_html)\"\"\";\n$(indentation)}}";
+
+            // Perform the replacement
+            string output_content;
+            try {
+                output_content = getter_regex.replace(vala_content, -1, 0, replacement);
+            } catch (RegexError e) {
+                throw new IOError.FAILED(@"Internal error: Failed to replace markup property: $(e.message)");
+            }
+
+            // Write output file
+            write_file_content(output_path, output_content);
+        }
+
+        private string read_file_content(string path) throws Error {
+            var file = File.new_for_path(path);
+            var input_stream = new DataInputStream(file.read());
+            var builder = new StringBuilder();
+
+            string line;
+            while ((line = input_stream.read_line(null)) != null) {
+                builder.append(line);
+                builder.append("\n");
+            }
+            input_stream.close();
+
+            return builder.str;
+        }
+
+        private void write_file_content(string path, string content) throws Error {
+            var file = File.new_for_path(path);
+            
+            // Ensure parent directory exists
+            var parent = file.get_parent();
+            if (parent != null && !parent.query_exists()) {
+                parent.make_directory_with_parents();
+            }
+
+            var output_stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.NONE));
+            output_stream.put_string(content);
+            output_stream.close();
+        }
+
+        private string extract_indentation(string content) {
+            // Find the line containing "public override string markup" and extract its leading whitespace
+            Regex indent_regex;
+            try {
+                indent_regex = new Regex(
+                    @"^([ \\t]*)public\\s+override\\s+string\\s+markup",
+                    RegexCompileFlags.MULTILINE
+                );
+            } catch (RegexError e) {
+                return "    "; // Default to 4 spaces
+            }
+
+            MatchInfo match;
+            if (indent_regex.match(content, 0, out match)) {
+                return match.fetch(1) ?? "    ";
+            }
+
+            return "    "; // Default to 4 spaces
+        }
+    }
+}