using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using Inversion; using Astralis; namespace Spry { public errordomain ComponentError { INVALID_TYPE, ELEMENT_NOT_FOUND, TYPE_NOT_FOUND, CONFLICTING_ATTRIBUTES, INVALID_TEMPLATE, INVALID_CONTEXT; } public abstract class Component : Object, Renderable { private static Dictionary templates; private static Mutex templates_lock = Mutex(); public string context_key { get; internal set; default = Uuid.string_random(); } public abstract string markup { get; } public virtual StatusCode get_status() { return StatusCode.OK; } public virtual async void prepare() throws Error { // No-op default } public virtual async void prepare_once() throws Error { // No-op default } public virtual async void handle_action(string action) throws Error { // No-op default } public virtual async void continuation(ContinuationContext continuation_context) throws Error { // No-op default } public virtual async void continuation_canceled() throws Error { // No-op default } private PathProvider _path_provider = inject(); private ContinuationProvider _continuation_provider = inject(); private ComponentFactory _component_factory = inject(); private CryptographyProvider _cryptography_provider = inject(); private Catalogue _children = new Catalogue(); private Dictionary _child_components = new Dictionary(); private HashSet _global_sources = new HashSet(); private HashSet _context_properties = new HashSet(); private MarkupDocument _instance; private bool _prepare_once_called; private MarkupDocument instance { get { if(_instance == null) { try { lock(_instance) { if(_instance == null) { templates_lock.lock (); if(templates == null) { templates = new Dictionary(); } var type = this.get_type(); ComponentTemplate template; if(!templates.try_get(type, out template)) { template = new ComponentTemplate(markup); templates[type] = template; } templates_lock.unlock(); _instance = template.new_instance(); } } } catch (Error e) { error(e.message); } } return _instance; }} protected Enumerable get_elements_by_class_name(string class_name) { return instance.get_elements_by_class_name(class_name); } protected Enumerable get_elements_by_tag_name(string tag_name) { return instance.get_elements_by_tag_name(tag_name); } protected new Enumerable query(string xpath) { return instance.select(xpath); } protected MarkupNode? query_one(string xpath) { return instance.select_one(xpath); } protected MarkupNode get_element_by_global_id(string global_id) { return instance.get_element_by_id(global_id); } protected new MarkupNode? @get(string spry_id) { return instance.select_one(@"//*[@sid='$(spry_id)']"); } protected void add_outlet_child(string outlet_id, Renderable renderable) { _children.add(outlet_id, renderable); } protected void add_outlet_children(string outlet_id, Enumerable renderables) { _children.add_all(outlet_id, renderables); } protected void set_outlet_children(string outlet_id, Enumerable renderables) { _children[outlet_id] = renderables; } protected void set_outlet_child(string outlet_id, Renderable renderable) { _children[outlet_id] = Iterate.single(renderable); } protected void clear_outlet_children(string outlet_id) { _children.clear_key(outlet_id); } protected void add_globals_from(Component component) { _global_sources.add(component); } protected T get_component_child(string sid) throws Error { var node = query_one(@"//spry-component[@sid='$sid']"); if(node == null) { throw new ComponentError.ELEMENT_NOT_FOUND(@"No spry-component element with sid '$sid' found."); } var component = get_component_instance_from_component_node(node); if(!component.get_type().is_a(typeof(T))) { throw new ComponentError.INVALID_TYPE(@"Component type $(component.get_type().name()) is not a $(typeof(T).name())"); } return component; } public async MarkupDocument to_document() throws Error { if(!_prepare_once_called) { yield prepare_once(); _prepare_once_called = true; } yield prepare(); var final_instance = instance.copy(); yield transform_document(final_instance); return final_instance; } internal async MarkupDocument get_dynamic_section(string name) throws Error { if(!_prepare_once_called) { yield prepare_once(); _prepare_once_called = true; } yield prepare(); // Extract the dynamic fragment var final_instance = instance.copy(); var template_fragment = final_instance.select_one(@"//*[@spry-dynamic='$name']")?.outer_html; if(template_fragment == null) { throw new ComponentError.ELEMENT_NOT_FOUND(@"Could not find spry-dynamic section '$name'."); } final_instance.body.inner_html = template_fragment; // Do regular transform yield transform_document(final_instance); return final_instance; } public async MarkupDocument get_globals_document() throws Error { if(!_prepare_once_called) { yield prepare_once(); _prepare_once_called = true; } yield prepare(); var final_instance = instance.copy(); yield transform_document(final_instance); // Extract out globals var globals = final_instance.select("//[@spry-global]"); var globals_document = new MarkupDocument(); globals_document.body.append_nodes(globals); return globals_document; } public async HttpResult to_result() throws Error { var document = yield to_document(); return document.to_result(get_status()); } private class ComponentTemplate : MarkupTemplate { private string _markup; protected override string markup { get { return _markup; } } public ComponentTemplate(string markup) { this._markup = markup; } } private Component get_component_instance_from_component_node(MarkupNode node) throws Error { Component component; // If no SID, create one to keep track of the instance var sid = node.get_attribute("sid"); if(sid == null) { sid = Uuid.string_random(); node.set_attribute("sid", sid); } if(!_child_components.try_get(sid, out component)) { component = _component_factory.create_by_name(node.get_attribute("name")); _child_components[sid] = component; } return component; } private async void transform_document(MarkupDocument doc) throws Error { transform_unique_attributes(doc); // Done first so as to ensure the tracking numbers don't change transform_if_attributes(doc); // Outputs spry-hidden attributes remove_hidden_blocks(doc); // Removes tags with spry-hidden attributes yield transform_per_attributes(doc); // Executes spry-per-* loops, which handles nested expression attributes yield transform_expression_attributes(doc); // Evaluares *-expr attributes yield transform_outlets(doc); yield transform_components(doc); transform_context_nodes(doc); transform_action_nodes(doc); transform_target_nodes(doc); transform_global_nodes(doc); transform_resource_nodes(doc); transform_dynamic_attributes(doc); transform_continuation_nodes(doc); remove_internal_sids(doc); yield append_globals(doc); } private async void transform_outlets(MarkupDocument doc) throws Error { var outlets = doc.select("//spry-outlet"); foreach (var outlet in outlets) { var nodes = new Series(); if(!outlet.has_attribute("sid")) { throw new ComponentError.INVALID_TEMPLATE("Tag spry-outlet must either define a content-expr or an sid"); } foreach(var renderable in _children.get_or_empty(outlet.get_attribute("sid"))) { var document = yield renderable.to_document(); nodes.add_all(document.body.children); } outlet.replace_with_nodes(nodes); } } private void remove_hidden_blocks(MarkupDocument doc) { doc.select("//*[@spry-hidden]") .iterate(n => n.remove()); } private void transform_action_nodes(MarkupDocument doc) throws Error { var action_nodes = doc.select("//*[@spry-action]"); foreach(var node in action_nodes) { var action = node.get_attribute("spry-action").split(":", 2); var component_name = action[0].replace(".", ""); if(component_name == "") { component_name = this.get_type().name(); } var component_action = action[1]; node.remove_attribute("spry-action"); if(component_name == this.get_type().name() && _context_properties.length > 0) { var data = new PropertyDictionary(); var root = new PropertyDictionary(); root["this"] = new NativeElement(this); var evaluation_context = new EvaluationContext(root); foreach(var prop_name in _context_properties) { data[prop_name] = ExpressionParser.parse(@"this.$(prop_name)").evaluate(evaluation_context); } var context = new ComponentContext() { type_name = this.get_type().name(), timestamp = new DateTime.now_utc(), context_key = context_key, data = data }; var context_blob = _cryptography_provider.author_component_context_blob(context); node.set_attribute("hx-get", _path_provider.get_action_path_with_context(component_name, component_action, context_blob)); } else { node.set_attribute("hx-get", _path_provider.get_action_path(component_name, component_action)); } } } private void transform_target_nodes(MarkupDocument doc) { var target_nodes = doc.select("//*[@spry-target]"); foreach(var node in target_nodes) { var target_node = doc.select_one(@"//*[@sid='$(node.get_attribute("spry-target"))']"); if(target_node.id == null) { target_node.id = "_spry-target-" + Uuid.string_random(); } node.set_attribute("hx-target", @"#$(target_node.id)"); node.remove_attribute("spry-target"); } } private void transform_global_nodes(MarkupDocument doc) { var global_nodes = doc.select("//*[@spry-global]"); foreach(var node in global_nodes) { var key = node.get_attribute("spry-global"); node.set_attribute("hx-swap-oob", @"[spry-global=\"$key\"]"); } } private void transform_resource_nodes(MarkupDocument doc) { var script_nodes = doc.select("//*[@spry-res]"); foreach(var node in script_nodes) { var res = node.get_attribute("spry-res"); if(res == null) { throw new ComponentError.INVALID_TEMPLATE("Attribute spry-res must have a value"); } node.remove_attribute("spry-res"); var path = "/_spry/res/" + res; if(node.tag_name == "script" || node.tag_name == "img") { node.set_attribute("src", path); continue; } if(node.tag_name == "link" && node.get_attribute("rel") == "stylesheet") { node.set_attribute("href", path); continue; } } } private void transform_continuation_nodes(MarkupDocument doc) { var continuation_nodes = doc.select("//*[@spry-continuation]"); foreach(var node in continuation_nodes) { var path = _continuation_provider.get_continuation_path(this); node.set_attribute("hx-ext", "sse"); node.set_attribute("sse-connect", path); node.set_attribute("sse-close", "_spry-close"); node.remove_attribute("spry-continuation"); } } private void remove_internal_sids(MarkupDocument doc) { doc.select("//*[@sid]") .iterate(n => n.remove_attribute("sid")); } private async void append_globals(MarkupDocument doc) throws Error { foreach(var source in _global_sources) { var globals = yield source.get_globals_document(); doc.body.append_nodes(globals.body.children); } } private async void transform_components(MarkupDocument doc) throws Error { var components = doc.select("//spry-component"); foreach (var component_node in components) { var component = get_component_instance_from_component_node(component_node); var document = yield component.to_document(); component_node.replace_with_nodes(document.body.children); } } private void transform_dynamic_attributes(MarkupDocument doc) throws Error { var nodes = doc.select("//*[@spry-dynamic]"); foreach (var node in nodes) { var name = node.get_attribute("spry-dynamic"); MarkupNode parent = node; while((parent = parent.parent) != null) { if(parent.has_attribute("spry-continuation")) { break; } } if(parent == null) { throw new ComponentError.INVALID_TEMPLATE("A tag with a spry-dynamic attribute must be the child of a tag with a spry-continuation attribute"); } node.set_attribute("sse-swap", @"_spry-dynamic-$name"); node.set_attribute("hx-swap", "outerHTML"); if(!node.has_attribute("id")) { node.set_attribute("id", @"_spry-dynamic-$name-$context_key"); node.set_attribute("id", @"_spry-dynamic-$name-$context_key"); } node.remove_attribute("spry-dynamic"); } } private void transform_unique_attributes(MarkupDocument doc) throws Error { var nodes = doc.select("//*[@spry-unique]"); var counter = 1000; foreach (var node in nodes) { if(node.has_attribute("id")) { throw new ComponentError.INVALID_TEMPLATE("Cannot specify id attribute for an element with a spry-unique attribute"); } if(node.get_attributes().keys.any(a => a.has_prefix("spry-per-")) || has_any_parent_where(node, n => n.get_attributes().keys.any(a => a.has_prefix("spry-per-")))) { throw new ComponentError.INVALID_TEMPLATE("The spry-unique attribute is not valid on any element or child of any element with a spry-per attribute"); } node.set_attribute("id", @"_spry-unique-$counter-$context_key"); node.set_attribute("id", @"_spry-unique-$counter-$context_key"); counter++; } } private void transform_if_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error { var root = new PropertyDictionary(); root["this"] = new NativeElement(this); var evaluation_context = context ?? new EvaluationContext(root); MarkupNode node; // Select one by one, so we don't have problems with nesting while((node = doc.select_one("//*[@spry-if or @spry-else-if or @spry-else]")) != null) { print(@"node $(node.tag_name)\n"); var expression_string = node.get_attribute("spry-if") ?? node.get_attribute("spry-else-if"); node.remove_attribute("spry-if"); node.remove_attribute("spry-else-if"); node.remove_attribute("spry-else"); if(expression_string == null) { print("null\n"); // else case continue; } var result = evaluate_if_expression(evaluation_context, expression_string, node); print(@"Result $(result)\n"); if(result) { // Hide any chained nodes MarkupNode chained_node; while((chained_node = node.next_element_sibling) != null) { if(chained_node.has_attribute("spry-else") || chained_node.get_attribute("spry-else-if") != null) { chained_node.remove(); } else { // If a sibling has no spry-else or spry-else-if it breaks the chain break; } } } else { // Mark this node for removal when condition is false node.remove(); } } } private bool evaluate_if_expression(EvaluationContext context, string expression_string, MarkupNode node) throws Error { var expression = ExpressionParser.parse(expression_string); var result = expression.evaluate(context); bool boolean_result; if(result.is()) { boolean_result = result.as(); } else if(result.is()) { boolean_result = result.as() != 0; } else { boolean_result = !result.is_null(); } return boolean_result; } private async void transform_per_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error { MarkupNode node; // Select one by one, so we don't have problems with nesting while((node = doc.nodes.first_or_default(n => n.get_attributes().any(a => a.key.has_prefix("spry-per-")))) != null) { var attribute = node.get_attributes().first(s => s.key.has_prefix("spry-per-")); var root = new PropertyDictionary(); root["this"] = new NativeElement(this); var evaluation_context = context ?? new EvaluationContext(root); var expression = ExpressionParser.parse(attribute.value); var result = expression.evaluate(evaluation_context); if(!result.assignable_to()) { throw new ComponentError.INVALID_TYPE(@"The spry-per attribute must refer to a value of type Invercargill.Enumerable, '$(attribute.value)' evaluates to a $(result.type_name())"); } node.remove_attribute(attribute.key); var values = result.as().to_elements(); var output_nodes = new Series(); foreach(var value in values) { evaluation_context.root_values[attribute.key[9:]] = value; var fragment = new MarkupDocument(); fragment.body.append_node(node); // Basic transform pipeline for fragment, the rest will get processed as part // of the wider component document later. transform_if_attributes(fragment, evaluation_context); remove_hidden_blocks(fragment); yield transform_per_attributes(fragment, evaluation_context); yield transform_expression_attributes(fragment, evaluation_context); output_nodes.add_all(fragment.body.children); } node.replace_with_nodes(output_nodes); } } private void transform_context_nodes(MarkupDocument doc) throws Error { var nodes = doc.select("//spry-context"); // Can't check for suffixes with xpath so iterate all nodes with attributes foreach (var node in nodes) { var property_name = node.get_attribute("property"); if(property_name == null) { throw new ComponentError.INVALID_TEMPLATE("Tag spry-context must have a property attribute"); } _context_properties.add(property_name); node.remove(); } } private async void transform_expression_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error { var nodes = doc.select("//*[@*]"); // Can't check for suffixes with xpath so iterate all nodes with attributes foreach (var node in nodes) { var attributes = node.get_attributes(); foreach (var attribute in attributes) { if(!attribute.key.has_suffix("-expr")) { continue; } var real_attribute = attribute.key.substring(0, attribute.key.length - 5); var root = new PropertyDictionary(); root["this"] = new NativeElement(this); var evaluation_context = context ?? new EvaluationContext(root); var expression = ExpressionParser.parse(attribute.value); var result = expression.evaluate(evaluation_context); node.remove_attribute(attribute.key); // class.* can be boolean if(result.type().is_a(typeof(bool)) && real_attribute.has_prefix("class-")) { var class_name = real_attribute.split("-", 2)[1]; if(result.as()) { if(!node.has_class(class_name)) { node.add_class(class_name); } } else { node.remove_class(class_name); } continue; } if(real_attribute == "content" && result.type().is_a(typeof(Renderable))) { var renderable = result.as(); var document = yield renderable.to_document(); if(node.tag_name == "spry-outlet") { if(node.get_attribute("sid") != null) { throw new ComponentError.CONFLICTING_ATTRIBUTES("Tag 'spry-outlet' cannot have both a 'content-expr' and 'sid' attribute"); } node.replace_with_nodes(document.body.children); } else { node.clear_children(); node.append_nodes(document.body.children); } continue; } // everything else read as string var str_value = result.as(); if(real_attribute == "content") { node.text_content = str_value; continue; } if(real_attribute.has_prefix("style-")) { var style_name = real_attribute.split("-", 2)[1]; node.set_style(style_name, str_value); continue; } node.set_attribute(real_attribute, str_value); } } } private bool has_any_parent_where(MarkupNode node, PredicateDelegate predicate) { var current = node; while((current = current.parent) != null) { if(predicate(current)) { return true; } } return false; } } }