Эх сурвалжийг харах

feat(core): add startup registration, fragment support, and GObject introspection

- Add startup/ephemeral registration pattern with add_startup, add_startup_endpoint, and add_startup_component methods
- Add add_module method for module registration
- Add MarkupDocument.copy() for deep copying documents
- Add MarkupDocument.fragment property for HTML fragment handling
- Add MarkupNode.replace_with_node() and replace_with_nodes() methods
- Add outer_html setter for replacing element content
- Change MarkupTemplate.get_markup() to markup property
- Fix add_scoped and add_singleton to use correct container methods
- Add initialization timing output in run()
- Add pkg-config generation and GObject introspection (typelib) to build
- Version library name with project version (libastralis-0.1.so)

BREAKING CHANGE: MarkupTemplate.get_markup() replaced with markup property.
Add_singleton_endpoint behavior changed - use add_startup_endpoint for
ephemeral resources. add_scoped and add_singleton now correctly use their
respective container registration methods instead of register_transient.
Billy Barrow 1 долоо хоног өмнө
parent
commit
1fcaff2a6c

+ 4 - 4
examples/DocumentBuilderTemplate.vala

@@ -216,7 +216,7 @@ class CounterTemplate : MarkupTemplate {
     /// Returns the HTML markup for this template.
     /// CSS is linked externally via /styles.css for better caching.
     /// </summary>
-    protected override string get_markup() {
+    protected override string markup { get {
         return """<!DOCTYPE html>
 <html lang="en">
 <head>
@@ -306,7 +306,7 @@ if (counter > 0) {
 </body>
 </html>
 """;
-    }
+    }}
 }
 
 // Main page endpoint - uses field initializer injection
@@ -502,11 +502,11 @@ void main(string[] args) {
         // Register the template as a singleton - it will be parsed once and cached
         // Each request gets a copy via new_instance()
         // Endpoints use field initializer injection with Inversion.inject<T>()
-        application.add_singleton<CounterTemplate>();
+        application.add_startup<CounterTemplate>();
         
         // Register CSS as a FastResource - pre-compressed, cached in memory
         // This demonstrates serving static content efficiently with ETag caching
-        application.add_singleton_endpoint<FastResource>(new EndpointRoute("/styles.css"), () => {
+        application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles.css"), () => {
             try {
                 return new FastResource.from_string(COUNTER_CSS)
                     .with_content_type("text/css; charset=utf-8")

+ 5 - 5
examples/FastResources.vala

@@ -15,7 +15,7 @@ using Invercargill.DataStructures;
  *   2. from_byte_array - For serving binary data (like images)
  *   3. Default constructor - For loading files from the filesystem
  * 
- * Note: FastResource should be registered as a SINGLETON since it holds
+ * Note: FastResource should be registered as a STARTUP or SINGLETON since it holds
  * pre-loaded content. The factory delegate creates the instance, and the
  * container caches it for subsequent requests.
  * 
@@ -563,8 +563,8 @@ void main(string[] args) {
         var application = new WebApplication(port);
         
         // 1. Home page using FastResource.from_string
-        // Register as singleton - the factory creates the instance and the container caches it
-        application.add_singleton_endpoint<FastResource>(new EndpointRoute("/"), () => {
+        // Register as startup - the factory creates the instance and the container caches it
+        application.add_startup_endpoint<FastResource>(new EndpointRoute("/"), () => {
             try {
                 return new FastResource.from_string(HOME_PAGE_HTML)
                     .with_content_type("text/html; charset=utf-8")
@@ -575,7 +575,7 @@ void main(string[] args) {
         });
         
         // 2. Image using FastResource.from_byte_array
-        application.add_singleton_endpoint<FastResource>(new EndpointRoute("/cat.webp"), () => {
+        application.add_startup_endpoint<FastResource>(new EndpointRoute("/cat.webp"), () => {
             try {
                 return new FastResource.from_byte_array(CAT_PHOTO)
                     .with_content_type("image/webp")
@@ -586,7 +586,7 @@ void main(string[] args) {
         });
         
         // 3. Running binary using FastResource default constructor
-        application.add_singleton_endpoint<FastResource>(new EndpointRoute("/binary"), () => new FastResource(binary_path)
+        application.add_startup_endpoint<FastResource>(new EndpointRoute("/binary"), () => new FastResource(binary_path)
                     .with_content_type("application/octet-stream")
                     .with_default_compressors());
         

+ 2 - 2
examples/HtmxExample.vala

@@ -374,7 +374,7 @@ pre {
  * HtmxTemplate - Main page template with htmx CDN script.
  */
 class HtmxTemplate : MarkupTemplate {
-    protected override string get_markup() {
+    protected override string markup { get {
         return """<!DOCTYPE html>
 <html lang="en">
 <head>
@@ -525,7 +525,7 @@ class HtmxTemplate : MarkupTemplate {
 </body>
 </html>
 """;
-    }
+    }}
 }
 
 // Helper function to render tasks HTML

+ 1 - 1
meson.build

@@ -1,5 +1,5 @@
 project('astralis', ['c', 'vala'],
-  version: '0.1.0',
+  version: '0.1',
 )
 
 # Dependencies

+ 341 - 0
plans/sse-implementation-plan.md

@@ -0,0 +1,341 @@
+# Server-Sent Events (SSE) Implementation Plan
+
+## Overview
+
+This plan outlines the implementation of Server-Sent Events (SSE) support for Astralis, following the existing architectural paradigm where objects outside the `Server/` folder have no knowledge of server internals.
+
+## Architectural Analysis
+
+### Existing Patterns
+
+The current architecture uses these key abstractions:
+
+```mermaid
+classDiagram
+    class HttpResult {
+        +Dictionary headers
+        +StatusCode status
+        +HttpResultFlag flags
+        +send_body_async AsyncOutput output
+    }
+    
+    class AsyncOutput {
+        <<interface>>
+        +write_async BinaryData data
+        +write_stream_async InputStream stream
+    }
+    
+    class ServerOutput {
+        -Series chunks
+        +write_async BinaryData data
+        +read_chunk void* buffer size_t max
+        +on_new_chunk signal
+    }
+    
+    class ResponseContext {
+        +HttpResult result
+        +ServerOutput body_output
+        +begin_response
+        +suspend_connection
+    }
+    
+    HttpResult <|-- HttpDataResult
+    HttpResult <|-- HttpStreamResult
+    HttpResult <|-- HttpEmptyResult
+    AsyncOutput <|.. ServerOutput
+    ResponseContext --> ServerOutput
+    ResponseContext --> HttpResult
+```
+
+### Key Design Principle
+
+**Separation of Concerns:**
+- `Core/` - Public abstractions visible to endpoints
+- `Server/` - Internal implementation details marked `internal`
+- Endpoints interact only with `Core/` types
+
+## Proposed Design
+
+### Component Overview
+
+```mermaid
+classDiagram
+    class SseEvent {
+        +string id
+        +string event_type
+        +string data
+        +int64 retry
+        +string format
+    }
+    
+    class SseChannel {
+        <<interface>>
+        +send_event_async SseEvent event
+        +send_async string data
+        +CancellationToken cancellation_token
+    }
+    
+    class SseHandler {
+        <<interface>>
+        +handle_sse_async SseChannel channel
+    }
+    
+    class HttpSseResult {
+        +int64 retry_interval
+        +SseHandler handler
+        +send_body_async AsyncOutput output
+    }
+    
+    class ServerSseChannel {
+        -AsyncOutput output
+        -CancellationToken token
+        +send_event_async SseEvent event
+        +send_async string data
+    }
+    
+    class CancellationToken {
+        +bool is_cancelled
+        +cancel
+        +on_cancelled signal
+    }
+    
+    HttpResult <|-- HttpSseResult
+    SseChannel <|.. ServerSseChannel
+    ServerSseChannel --> AsyncOutput
+    ServerSseChannel --> CancellationToken
+    HttpSseResult --> SseChannel
+    HttpSseResult --> SseHandler
+```
+
+### File Structure
+
+```
+src/
+├── Core/
+│   ├── SseEvent.vala           # Public: SSE event data structure
+│   ├── SseChannel.vala         # Public: Interface for sending SSE events
+│   ├── SseHandler.vala         # Public: Interface for SSE handlers - similar to Endpoint
+│   ├── CancellationToken.vala  # Public: Token for manual cancellation
+│   └── HttpSseResult.vala      # Public: HttpResult subclass for SSE
+└── Server/
+    └── ServerSseChannel.vala   # Internal: ServerOutput-backed implementation
+```
+
+## Detailed Component Specifications
+
+### 1. SseEvent - Core/SseEvent.vala
+
+A simple data class representing an SSE event following the W3C specification.
+
+```vala
+public class SseEvent : Object {
+    public string? id { get; set; }
+    public string? event_type { get; set; }
+    public string data { get; set; }
+    public int64? retry { get; set; }
+    
+    public SseEvent(string data);
+    public SseEvent.with_id(string id, string data);
+    public SseEvent.with_type(string event_type, string data);
+    
+    public string format();  // Returns properly formatted SSE string
+}
+```
+
+**Format output example:**
+```
+id: 123
+event: message
+data: Hello World
+retry: 3000
+
+```
+
+### 2. CancellationToken - Core/CancellationToken.vala
+
+A cancellation token for manual connection management.
+
+```vala
+public class CancellationToken : Object {
+    public bool is_cancelled { get; private set; }
+    public signal void cancelled();
+    
+    public void cancel();
+}
+```
+
+### 3. SseChannel - Core/SseChannel.vala
+
+Interface for sending SSE events - endpoints use this to push data.
+
+```vala
+public interface SseChannel : Object {
+    public abstract async void send_event(SseEvent event) throws Error;
+    public abstract async void send_data(string data) throws Error;
+    public abstract CancellationToken cancellation_token { get; }
+}
+```
+
+### 4. SseHandler - Core/SseHandler.vala
+
+Interface for SSE handlers - follows the same pattern as Endpoint.
+
+```vala
+public interface SseHandler : Object {
+    public abstract async void handle_sse(SseChannel channel) throws Error;
+}
+```
+
+### 5. HttpSseResult - Core/HttpSseResult.vala
+
+HttpResult subclass that enables SSE streaming.
+
+```vala
+public class HttpSseResult : HttpResult {
+    public int64 retry_interval { get; set; }
+    public SseHandler handler { get; set; }
+    
+    public HttpSseResult();
+    public HttpSseResult.with_retry(int64 retry_ms);
+    public HttpSseResult.with_handler(SseHandler handler);
+    
+    public override async void send_body(AsyncOutput output) throws Error;
+}
+```
+
+### 6. ServerSseChannel - Server/ServerSseChannel.vala
+
+Internal implementation that wraps an AsyncOutput.
+
+```vala
+internal class ServerSseChannel : Object, SseChannel {
+    private AsyncOutput output;
+    private CancellationToken cancellation_token;
+    
+    public ServerSseChannel(AsyncOutput output, CancellationToken token);
+    
+    public async void send_event(SseEvent event) throws Error;
+    public async void send_data(string data) throws Error;
+}
+```
+
+## Usage Example
+
+```vala
+// Define an SSE handler class
+class StockTickerHandler : Object, SseHandler {
+    public async void handle_sse(SseChannel channel) throws Error {
+        while (!channel.cancellation_token.is_cancelled) {
+            var price = yield get_latest_stock_price();
+            var evt = new SseEvent.with_type("price-update", price.to_string());
+            yield channel.send_event(evt);
+            yield AsyncUtil.delay_async(1000);  // Wait 1 second
+        }
+    }
+}
+
+// Use in endpoint
+class StockTickerEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext ctx, RouteContext route) throws Error {
+        return new HttpSseResult.with_handler(new StockTickerHandler());
+    }
+}
+```
+
+## Alternative: Anonymous Class Pattern
+
+For simpler cases, Vala allows inline class implementation:
+
+```vala
+class SimpleSseEndpoint : Object, Endpoint {
+    public async HttpResult handle_request(HttpContext ctx, RouteContext route) throws Error {
+        var handler = new Object() with SseHandler {
+            public async void handle_sse(SseChannel channel) throws Error {
+                while (!channel.cancellation_token.is_cancelled) {
+                    yield channel.send_data("ping");
+                    Timeout.add(1000, () => {
+                        handle_sse.callback();
+                        return false;
+                    });
+                    yield;
+                }
+            }
+        };
+        return new HttpSseResult.with_handler(handler);
+    }
+}
+```
+
+## Implementation Steps
+
+### Phase 1: Core Abstractions
+1. Create `SseEvent` class in `Core/SseEvent.vala`
+2. Create `CancellationToken` class in `Core/CancellationToken.vala`
+3. Create `SseChannel` interface in `Core/SseChannel.vala`
+4. Create `SseHandler` interface in `Core/SseHandler.vala`
+
+### Phase 2: HttpResult Integration
+5. Create `HttpSseResult` class in `Core/HttpSseResult.vala`
+6. Update `meson.build` to include new files
+
+### Phase 3: Server Implementation
+7. Create `ServerSseChannel` in `Server/ServerSseChannel.vala`
+
+### Phase 4: Example and Testing
+8. Create example endpoint in `examples/SseExample.vala`
+9. Update `examples/meson.build`
+
+## Design Decisions and Rationale
+
+### Why SseChannel Interface?
+
+Following the existing pattern where `AsyncOutput` is an interface in Core but `ServerOutput` is an internal implementation, `SseChannel` provides:
+- Clean separation between public API and server implementation
+- Testability - mock implementations for unit tests
+- Flexibility - alternative implementations possible
+
+### Why CancellationToken vs Automatic Detection?
+
+User preference for manual management. Benefits:
+- Explicit control in endpoint code
+- Clear resource ownership
+- Predictable cleanup behavior
+- Endpoint can perform cleanup operations before terminating
+
+### Why SseHandler Interface Instead of Async Delegate?
+
+Vala does not support async delegates. Using an interface:
+- Follows the same pattern as `Endpoint` in the existing codebase
+- Enables IoC container registration and dependency injection
+- Provides clear contract for SSE handlers
+- Allows both named classes and anonymous implementations
+
+## SSE Protocol Compliance
+
+The implementation follows the W3C Server-Sent Events specification:
+- Content-Type: `text/event-stream`
+- Events formatted with `field: value` syntax
+- Events separated by blank lines
+- Support for `id`, `event`, `data`, and `retry` fields
+- Automatic reconnection via `retry` field
+
+## Integration with Existing Components
+
+### Compression Pipeline
+SSE streams should set `DO_NOT_COMPRESS` flag since:
+- Content is text-based and already efficient
+- Compression adds latency
+- Real-time nature conflicts with compression buffering
+
+### Response Headers
+`HttpSseResult` automatically sets:
+- `Content-Type: text/event-stream`
+- `Cache-Control: no-cache`
+- `Connection: keep-alive`
+
+## Minimal Feature Set
+
+Based on discussion, keeping features minimal:
+- No built-in Last-Event-ID support (endpoints can handle if needed)
+- No built-in heartbeat (endpoints can send comments if needed)
+- No connection timeout (endpoints manage via CancellationToken)

+ 28 - 5
src/Core/WebApplication.vala

@@ -18,21 +18,32 @@ namespace Astralis {
             server = new Server(this.port, pipeline);
         }
 
-        public void run() {
+        public void run() throws Error {
             // Ensure router is registered last so that it is the last component in the pipeline.
             add_component<EndpointRouter>();
 
+            var timer = new Timer();
+            timer.start();
+            container.initialise();
+            timer.stop();
+            printerr(@"[Astralis] Intialised application in $((int)(timer.elapsed() * 1000))ms\n");
             server.run();
         }
 
         public Registration add_endpoint<T>(EndpointRoute route, owned TypedFactoryDelegate<T>? endpoint_func = null) {
-            return container.register_scoped<T>((owned) endpoint_func)
+            return add_scoped<T>((owned) endpoint_func)
                 .with_metadata<EndpointRoute>(route)
                 .as<Endpoint>();
         }
 
         public Registration add_singleton_endpoint<T>(EndpointRoute route, owned TypedFactoryDelegate<T>? endpoint_func = null) {
-            return container.register_singleton<T>((owned) endpoint_func)
+            return add_singleton<T>((owned) endpoint_func)
+                .with_metadata<EndpointRoute>(route)
+                .as<Endpoint>();
+        }
+
+        public Registration add_startup_endpoint<T>(EndpointRoute route, owned TypedFactoryDelegate<T>? endpoint_func = null) {
+            return add_startup<T>((owned) endpoint_func)
                 .with_metadata<EndpointRoute>(route)
                 .as<Endpoint>();
         }
@@ -45,16 +56,28 @@ namespace Astralis {
             return add_singleton<T>(construct_func).as<PipelineComponent>();
         }
 
+        public Registration add_startup_component<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
+            return add_startup<T>(construct_func).as<PipelineComponent>();
+        }
+
         public Registration add_transient<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
             return container.register_transient<T>(construct_func);
         }
 
         public Registration add_scoped<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
-            return container.register_transient<T>(construct_func);
+            return container.register_scoped<T>(construct_func);
         }
 
         public Registration add_singleton<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
-            return container.register_transient<T>(construct_func);
+            return container.register_singleton<T>(construct_func);
+        }
+
+        public Registration add_startup<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
+            return container.register_startup<T>(construct_func);
+        }
+
+        public Registration add_module<T>(owned TypedFactoryDelegate<T>? construct_func = null) throws Error {
+            return container.register_module<T>(construct_func);
         }
 
         public void use_compression() {

+ 1 - 0
src/Endpoints/FastResource.vala

@@ -52,6 +52,7 @@ namespace Astralis {
         public FastResource with_compressor(Compressor compressor) throws Error {
             var encoded = compressor.compress_buffer(encodings["identity"], headers.get_or_default("Content-Type"));
             if(encoded.length >= encodings["identity"].length) {
+                print(@"Skipped compressor since encoded length ($(encoded.length)) > resource length ($(encodings["identity"].length))\n");
                 return this; // Skip, since the compressed version would be larger
             }
 

+ 31 - 13
src/Markup/MarkupDocument.vala

@@ -8,6 +8,7 @@ namespace Astralis {
     /// Represents an HTML document that can be loaded, manipulated, and rendered
     /// </summary>
     public class MarkupDocument : GLib.Object {
+        public bool fragment { get; private set; }
         private Html.Doc* doc;
 
         /// <summary>
@@ -56,9 +57,30 @@ namespace Astralis {
         /// This is primarily used by MarkupTemplate to create instances from cached templates.
         /// </summary>
         /// <param name="existing_doc">An existing Html.Doc pointer that this document will own</param>
-        public MarkupDocument.from_doc(owned Html.Doc* existing_doc) {
+        internal MarkupDocument.from_doc(owned Html.Doc* existing_doc) {
             doc = existing_doc;
         }
+        
+        /// <summary>
+        /// Creates a deep copy of this document.
+        /// The returned document is completely independent and can be safely modified
+        /// without affecting the original document.
+        /// </summary>
+        /// <returns>A new MarkupDocument that is a deep copy of this document</returns>
+        /// <throws>GLib.Error if the document cannot be copied</throws>
+        public MarkupDocument copy() throws GLib.Error {
+            // Create a deep copy of the document (1 = recursive/deep copy)
+            // Note: copy() returns Xml.Doc* but Html.Doc is a subclass, so we cast
+            Xml.Doc* xml_copy = doc->copy(1);
+            
+            if (xml_copy == null) {
+                throw new MarkupError.PARSE_ERROR("Failed to copy document");
+            }
+            
+            var copied_doc = new MarkupDocument.from_doc((Html.Doc*)xml_copy);
+            copied_doc.fragment = this.fragment;
+            return copied_doc;
+        }
 
         private void parse_html(string html) throws GLib.Error {
             int options = (int)(Html.ParserOption.RECOVER |
@@ -75,6 +97,7 @@ namespace Astralis {
                 string wrapped = "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"/></head><body>%s</body></html>".printf(html);
                 char[] wrapped_buffer = wrapped.to_utf8();
                 doc = Html.Doc.read_memory(wrapped_buffer, wrapped_buffer.length, "", null, options);
+                fragment = true;
             }
             
             if (doc == null) {
@@ -103,6 +126,7 @@ namespace Astralis {
         /// </summary>
         public MarkupNode? head {
             owned get {
+                if(fragment) return null;
                 var root = doc->get_root_element();
                 if (root == null) return null;
                 
@@ -234,8 +258,9 @@ namespace Astralis {
         /// <summary>
         /// Creates a new text node
         /// </summary>
-        public Xml.Node* create_text_node(string text) {
-            return doc->new_text(text);
+        public MarkupNode create_text_node(string text) {
+            var node = doc->new_text(text);
+            return new MarkupNode(this, node);
         }
 
         internal Xml.Node* import_node(Xml.Node* node, bool deep = true) {
@@ -246,22 +271,15 @@ namespace Astralis {
         /// Converts the document to an HTML string using libxml's HTML serializer
         /// </summary>
         public string to_html() {
+            if(fragment) {
+                return body.inner_html;
+            }
             string buffer;
             int len;
             doc->dump_memory(out buffer, out len);
             return buffer ?? "";
         }
 
-        /// <summary>
-        /// Converts the document to a formatted HTML string with indentation
-        /// </summary>
-        public string to_pretty_html() {
-            string buffer;
-            int len;
-            doc->dump_memory_format(out buffer, out len, true);
-            return buffer ?? "";
-        }
-
         /// <summary>
         /// Saves the document to a file
         /// </summary>

+ 60 - 41
src/Markup/MarkupNode.vala

@@ -11,7 +11,7 @@ namespace Astralis {
         private Xml.Node* xml_node;
         private MarkupDocument document;
 
-        internal MarkupNode(MarkupDocument doc, Xml.Node* node) {
+        public MarkupNode(MarkupDocument doc, Xml.Node* node) {
             this.document = doc;
             this.xml_node = node;
         }
@@ -280,42 +280,28 @@ namespace Astralis {
         }
 
         /// <summary>
-        /// Replaces this element with new content
+        /// Replaces this element with another MarkupNode
         /// </summary>
-        public void replace_with_html(string html) {
-            // Parse the HTML fragment using HTML parser
-            int options = (int)(Html.ParserOption.RECOVER |
-                Html.ParserOption.NOERROR |
-                Html.ParserOption.NOWARNING |
-                Html.ParserOption.NOBLANKS |
-                Html.ParserOption.NONET);
+        /// <param name="node">The node to replace this element with</param>
+        public void replace_with_node(MarkupNode node) {
+            // Insert the new node before this node
+            xml_node->add_prev_sibling(node.native);
             
-            string wrapped = "<div>" + html + "</div>";
-            char[] buffer = wrapped.to_utf8();
-            var temp_doc = Html.Doc.read_memory(buffer, buffer.length, "", null, options);
-            if (temp_doc == null) {
-                return;
-            }
-            
-            var temp_root = temp_doc->get_root_element();
-            if (temp_root == null) {
-                delete temp_doc;
-                return;
-            }
+            // Remove this node
+            remove();
+        }
 
-            // Import and insert children before this node
-            var parent = xml_node->parent;
-            if (parent != null) {
-                for (var child = temp_root->children; child != null; child = child->next) {
-                    var imported = doc.import_node(child, true);
-                    parent->add_prev_sibling(imported);
-                    parent = imported; // Move insertion point
-                }
+        public void replace_with_nodes(Enumerable<MarkupNode> nodes) {           
+            foreach(var node in nodes) {
+                xml_node->add_prev_sibling(node.native);
             }
-            
-            // Remove this node
             remove();
-            delete temp_doc;
+        }
+
+        public void append_document_contents(MarkupDocument document) {
+            foreach(var child in document.body.children) {
+                xml_node->add_child(child.native);
+            }
         }
 
         /// <summary>
@@ -393,6 +379,41 @@ namespace Astralis {
         /// Gets the outer HTML of this element (including the element itself)
         /// </summary>
         public string outer_html {
+            owned set {
+                // Parse the HTML fragment using HTML parser
+                int options = (int)(Html.ParserOption.RECOVER |
+                    Html.ParserOption.NOERROR |
+                    Html.ParserOption.NOWARNING |
+                    Html.ParserOption.NOBLANKS |
+                    Html.ParserOption.NONET);
+                
+                string wrapped = "<div>" + value + "</div>";
+                char[] buffer = wrapped.to_utf8();
+                var temp_doc = Html.Doc.read_memory(buffer, buffer.length, "", null, options);
+                if (temp_doc == null) {
+                    return;
+                }
+                
+                var temp_root = temp_doc->get_root_element();
+                if (temp_root == null) {
+                    delete temp_doc;
+                    return;
+                }
+
+                // Import and insert children before this node
+                var parent = xml_node->parent;
+                if (parent != null) {
+                    for (var child = temp_root->children; child != null; child = child->next) {
+                        var imported = doc.import_node(child, true);
+                        parent->add_prev_sibling(imported);
+                        parent = imported; // Move insertion point
+                    }
+                }
+                
+                // Remove this node
+                remove();
+                delete temp_doc;
+            }
             owned get {
                 // Create a temporary HTML document to serialize this node
                 var temp_doc = new Html.Doc();
@@ -545,7 +566,7 @@ namespace Astralis {
         /// Gets a tracker for iterating over the nodes
         /// </summary>
         public override Invercargill.Tracker<MarkupNode> get_tracker() {
-            return new NodeTracker(document, nodes);
+            return new NodeTracker(this);
         }
 
         /// <summary>
@@ -559,31 +580,29 @@ namespace Astralis {
         /// Tracker for iterating over MarkupNodeList
         /// </summary>
         private class NodeTracker : Invercargill.Tracker<MarkupNode> {
-            private MarkupDocument document;
-            private unowned List<Xml.Node*> nodes;
+            private MarkupNodeList list;
             private unowned List<Xml.Node*>? current;
 
-            internal NodeTracker(MarkupDocument doc, List<Xml.Node*> nodes) {
-                this.document = doc;
-                this.nodes = nodes;
+            internal NodeTracker(owned MarkupNodeList list) {
+                this.list = list;
                 this.current = null;
             }
 
             public override MarkupNode get_next() {
                 if (current == null) {
-                    current = nodes.first();
+                    current = list.nodes.first();
                 } else {
                     current = current.next;
                 }
                 // has_next() should be called before get_next()
                 // Returning null here would violate the non-nullable contract
                 assert(current != null);
-                return new MarkupNode(document, current.data);
+                return new MarkupNode(list.document, current.data);
             }
 
             public override bool has_next() {
                 if (current == null) {
-                    return !nodes.is_empty();
+                    return !list.nodes.is_empty();
                 }
                 return current.next != null;
             }

+ 2 - 2
src/Markup/MarkupTemplate.vala

@@ -21,7 +21,7 @@ namespace Astralis {
         /// Subclasses should override this to provide the template source,
         /// either from a constant string or by calling read_file().
         /// </summary>
-        protected abstract string get_markup();
+        protected abstract string markup { get; }
         
         /// <summary>
         /// Reads the contents of a file and returns it as a string.
@@ -54,7 +54,7 @@ namespace Astralis {
                     return;
                 }
                 
-                string html = get_markup();
+                string html = markup;
                 
                 int options = (int)(Html.ParserOption.RECOVER |
                     Html.ParserOption.NOERROR |

+ 20 - 2
src/meson.build

@@ -26,12 +26,30 @@ sources = files(
     'Markup/MarkupTemplate.vala',
 )
 
-libastralis = shared_library('astralis',
+
+library_version = meson.project_version()
+libastralis = shared_library('astralis-@0@'.format(library_version),
     sources,
     dependencies: [glib_dep, gobject_dep, mhd_dep, gio_dep, gio_unix_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, zlib_dep, brotli_dep, zstd_dep, inversion_dep, libxml_dep],
-    install: true
+    install: true,
+    vala_gir: 'astralis-@0@.gir'.format(library_version),
+    install_dir: [true, true, true, true]
 )
 
+
+pkg = import('pkgconfig')
+pkg.generate(libastralis,
+    version : library_version,
+    name : 'astralis-@0@'.format(library_version))
+    
+g_ir_compiler = find_program('g-ir-compiler')
+custom_target('astralis typelib', command: [g_ir_compiler, '--shared-library=libastralis-@0@.so'.format(library_version), '--output', '@OUTPUT@', meson.current_build_dir() / 'astralis-@0@.gir'.format(library_version)],
+              output: 'libastralis-@0@.typelib'.format(library_version),
+              depends: libastralis,
+              install: true,
+              install_dir: get_option('libdir') / 'girepository-1.0')
+
+
 astralis_dep = declare_dependency(
     link_with: libastralis,
     include_directories: include_directories('.'),