Forráskód Böngészése

feat(markup): add MarkupTemplate class for cached HTML templates

- Add MarkupTemplate base class that parses HTML once and caches it
- Add MarkupDocument.from_doc() constructor for creating documents from cached templates
- Add convenience DI methods to WebApplication (add_singleton, add_scoped, add_transient, add_component)
- Rename add_static_endpoint to add_singleton_endpoint for clarity
- Update DocumentBuilderTemplate example to demonstrate template injection pattern
Billy Barrow 1 hete
szülő
commit
59d10ce13f

+ 43 - 17
examples/DocumentBuilderTemplate.vala

@@ -1,16 +1,18 @@
 using Astralis;
 using Invercargill;
 using Invercargill.DataStructures;
+using Inversion;
 
 /**
  * DocumentBuilderTemplate Example
  * 
  * Demonstrates loading an existing HTML template and modifying elements
  * using the DocumentModel classes. Shows how to:
- *   - Load HTML from a string template
+ *   - Define a MarkupTemplate subclass with embedded HTML
+ *   - Register the template as a singleton with WebApplication
+ *   - Inject and use the template in endpoints
  *   - Use XPath selectors to find specific elements
  *   - Modify element content, attributes, and classes
- *   - Add new elements dynamically
  *   - Handle form POST to update the document state
  * 
  * This example complements DocumentBuilder.vala by showing template-based
@@ -55,12 +57,19 @@ class AppState : Object {
     }
 }
 
-// Global app state
-AppState app_state;
-
-// HTML template loaded as a constant
-private const string HTML_TEMPLATE = """
-<!DOCTYPE html>
+/**
+ * CounterTemplate - A cached HTML template for the counter page.
+ * 
+ * This class extends MarkupTemplate to provide a reusable, cached template.
+ * The HTML is parsed once and cached; new_instance() returns efficient copies.
+ */
+class CounterTemplate : MarkupTemplate {
+    /// <summary>
+    /// Returns the HTML markup for this template.
+    /// This could also use read_file() to load from disk.
+    /// </summary>
+    protected override string get_markup() {
+        return """<!DOCTYPE html>
 <html lang="en">
 <head>
     <meta charset="UTF-8"/>
@@ -290,12 +299,17 @@ if (counter > 0) {
 </body>
 </html>
 """;
+    }
+}
 
-// Main page endpoint - loads template and modifies it
+// Main page endpoint - uses field initializer injection
 class HomePageEndpoint : Object, Endpoint {
+    // Field initializer injection - template is injected by the DI container
+    private CounterTemplate template = Inversion.inject<CounterTemplate>();
+    
     public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        // Load the HTML template
-        var doc = new MarkupDocument.from_string(HTML_TEMPLATE);
+        // Get a copy of the cached template
+        var doc = template.new_instance();
         
         // Update the counter value
         var counter_el = doc.select_one("//div[@id='counter-value']");
@@ -388,9 +402,12 @@ class ResetEndpoint : Object, Endpoint {
 
 // Raw HTML endpoint - shows the unmodified template
 class RawHtmlEndpoint : Object, Endpoint {
+    // Field initializer injection
+    private CounterTemplate template = Inversion.inject<CounterTemplate>();
+    
     public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        // Load template without modifications
-        var doc = new MarkupDocument.from_string(HTML_TEMPLATE);
+        // Get a fresh copy of the template
+        var doc = template.new_instance();
         
         // Add a notice at the top
         var body = doc.body;
@@ -409,8 +426,11 @@ class RawHtmlEndpoint : Object, Endpoint {
 
 // API endpoint that demonstrates modifying multiple elements at once
 class BulkUpdateEndpoint : Object, Endpoint {
+    // Field initializer injection
+    private CounterTemplate template = Inversion.inject<CounterTemplate>();
+    
     public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        var doc = new MarkupDocument.from_string(HTML_TEMPLATE);
+        var doc = template.new_instance();
         
         // Demonstrate selecting multiple elements
         var all_info_values = doc.select("//div[contains(@class, 'info-value')]");
@@ -439,6 +459,9 @@ class BulkUpdateEndpoint : Object, Endpoint {
     }
 }
 
+// Global app state
+AppState app_state;
+
 void main(string[] args) {
     int port = args.length > 1 ? int.parse(args[1]) : 8080;
     
@@ -466,9 +489,12 @@ void main(string[] args) {
         var application = new WebApplication(port);
         
         // Register compression components
-        application.container.register_singleton<GzipCompressor>(() => new GzipCompressor());
-        application.container.register_singleton<ZstdCompressor>(() => new ZstdCompressor());
-        application.container.register_singleton<BrotliCompressor>(() => new BrotliCompressor());
+        application.use_compression();
+        
+        // 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>();
         
         // Register endpoints
         application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));

+ 3 - 3
examples/FastResources.vala

@@ -564,7 +564,7 @@ void main(string[] args) {
         
         // 1. Home page using FastResource.from_string
         // Register as singleton - the factory creates the instance and the container caches it
-        application.add_static_endpoint<FastResource>(new EndpointRoute("/"), () => {
+        application.add_singleton_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_static_endpoint<FastResource>(new EndpointRoute("/cat.webp"), () => {
+        application.add_singleton_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_static_endpoint<FastResource>(new EndpointRoute("/binary"), () => new FastResource(binary_path)
+        application.add_singleton_endpoint<FastResource>(new EndpointRoute("/binary"), () => new FastResource(binary_path)
                     .with_content_type("application/octet-stream")
                     .with_default_compressors());
         

+ 22 - 3
src/Core/WebApplication.vala

@@ -20,8 +20,7 @@ namespace Astralis {
 
         public void run() {
             // Ensure router is registered last so that it is the last component in the pipeline.
-            container.register_scoped<EndpointRouter>()
-                .as<PipelineComponent>();
+            add_component<EndpointRouter>();
 
             server.run();
         }
@@ -32,12 +31,32 @@ namespace Astralis {
                 .as<Endpoint>();
         }
 
-        public Registration add_static_endpoint<T>(EndpointRoute route, owned TypedFactoryDelegate<T>? endpoint_func = null) {
+        public Registration add_singleton_endpoint<T>(EndpointRoute route, owned TypedFactoryDelegate<T>? endpoint_func = null) {
             return container.register_singleton<T>((owned) endpoint_func)
                 .with_metadata<EndpointRoute>(route)
                 .as<Endpoint>();
         }
 
+        public Registration add_component<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
+            return add_scoped<T>(construct_func).as<PipelineComponent>();
+        }
+
+        public Registration add_singleton_component<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
+            return add_singleton<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);
+        }
+
+        public Registration add_singleton<T>(owned TypedFactoryDelegate<T>? construct_func = null) {
+            return container.register_transient<T>(construct_func);
+        }
+
         public void use_compression() {
             container.register_scoped<GzipCompressor>();
             container.register_scoped<ZstdCompressor>();

+ 10 - 0
src/Markup/MarkupDocument.vala

@@ -48,6 +48,16 @@ namespace Astralis {
         public MarkupDocument.from_string(string html) throws GLib.Error {
             parse_html(html);
         }
+        
+        /// <summary>
+        /// Creates a MarkupDocument from an existing Xml.Doc pointer.
+        /// The document takes ownership of the passed pointer and will free it on destruction.
+        /// This is primarily used by MarkupTemplate to create instances from cached templates.
+        /// </summary>
+        /// <param name="existing_doc">An existing Xml.Doc pointer that this document will own</param>
+        public MarkupDocument.from_doc(owned Xml.Doc* existing_doc) {
+            doc = existing_doc;
+        }
 
         private void parse_html(string html) throws GLib.Error {
             // Use XML parser with HTML recovery options

+ 146 - 0
src/Markup/MarkupTemplate.vala

@@ -0,0 +1,146 @@
+using Xml;
+using Invercargill;
+
+namespace Astralis {
+    /// <summary>
+    /// Abstract base class for HTML templates that caches parsed documents
+    /// and provides efficient cloning for request handling.
+    /// 
+    /// Subclasses should implement get_markup() to provide the HTML source.
+    /// The template is parsed once and cached; new_instance() returns copies
+    /// that can be safely modified without affecting the original.
+    /// </summary>
+    public abstract class MarkupTemplate : GLib.Object {
+        private Xml.Doc* cached_doc = null;
+        private bool loaded = false;
+        private GLib.Mutex mutex = GLib.Mutex();
+        
+        /// <summary>
+        /// Returns the HTML markup string for this template.
+        /// 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();
+        
+        /// <summary>
+        /// Reads the contents of a file and returns it as a string.
+        /// Useful for implementing get_markup() when templates are stored on disk.
+        /// </summary>
+        /// <param name="path">The path to the file to read</param>
+        /// <returns>The file contents as a string</returns>
+        /// <throws>GLib.Error if the file cannot be read</throws>
+        protected string read_file(string path) throws GLib.Error {
+            var file = File.new_for_path(path);
+            if (!file.query_exists()) {
+                throw new MarkupError.FILE_NOT_FOUND("Template file not found: %s".printf(path));
+            }
+            
+            uint8[] contents;
+            string etag_out;
+            file.load_contents(null, out contents, out etag_out);
+            
+            return (string)contents;
+        }
+        
+        /// <summary>
+        /// Parses the template markup if not already loaded.
+        /// Thread-safe: uses mutex to prevent double-parsing.
+        /// </summary>
+        private void ensure_loaded() throws GLib.Error {
+            mutex.lock();
+            try {
+                if (loaded) {
+                    return;
+                }
+                
+                string html = get_markup();
+                
+                Parser.init();
+                
+                int options = (int)(ParserOption.RECOVER |
+                    ParserOption.NOERROR |
+                    ParserOption.NOWARNING |
+                    ParserOption.NOBLANKS |
+                    ParserOption.NONET);
+                
+                cached_doc = Parser.read_memory(html, html.length, null, null, options);
+                
+                if (cached_doc == null) {
+                    // Try parsing as a fragment wrapped in a basic structure
+                    string wrapped = "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"/></head><body>%s</body></html>".printf(html);
+                    cached_doc = Parser.read_memory(wrapped, wrapped.length, null, null, options);
+                }
+                
+                if (cached_doc == null) {
+                    throw new MarkupError.PARSE_ERROR("Failed to parse template markup");
+                }
+                
+                loaded = true;
+            } finally {
+                mutex.unlock();
+            }
+        }
+        
+        /// <summary>
+        /// Creates a new MarkupDocument instance from this template.
+        /// The returned document is a copy of the cached template and can be
+        /// safely modified without affecting the original template.
+        /// </summary>
+        /// <returns>A new MarkupDocument that is a copy of this template</returns>
+        /// <throws>GLib.Error if the template cannot be loaded or copied</throws>
+        public MarkupDocument new_instance() throws GLib.Error {
+            ensure_loaded();
+            
+            // Create a deep copy of the cached document (1 = recursive/deep copy)
+            Xml.Doc* copy = cached_doc->copy(1);
+            
+            if (copy == null) {
+                throw new MarkupError.PARSE_ERROR("Failed to copy template document");
+            }
+            
+            return new MarkupDocument.from_doc(copy);
+        }
+        
+        /// <summary>
+        /// Gets the root element name of the template (for debugging/validation).
+        /// </summary>
+        public string? root_element_name {
+            owned get {
+                try {
+                    ensure_loaded();
+                    var root = cached_doc->get_root_element();
+                    return root != null ? root->name : null;
+                } catch (GLib.Error e) {
+                    return null;
+                }
+            }
+        }
+        
+        /// <summary>
+        /// Returns true if the template has been loaded and cached.
+        /// </summary>
+        public bool is_loaded {
+            get { return loaded; }
+        }
+        
+        /// <summary>
+        /// Forces the template to be reloaded on the next new_instance() call.
+        /// Useful if the template source may have changed.
+        /// </summary>
+        public void invalidate() {
+            mutex.lock();
+            if (cached_doc != null) {
+                delete cached_doc;
+                cached_doc = null;
+            }
+            loaded = false;
+            mutex.unlock();
+        }
+        
+        ~MarkupTemplate() {
+            if (cached_doc != null) {
+                delete cached_doc;
+            }
+        }
+    }
+}

+ 1 - 0
src/meson.build

@@ -23,6 +23,7 @@ sources = files(
     'Markup/MarkupDocument.vala',
     'Markup/MarkupNode.vala',
     'Markup/MarkupError.vala',
+    'Markup/MarkupTemplate.vala',
 )
 
 libastralis = shared_library('astralis',