Component.vala 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using Inversion;
  5. using Astralis;
  6. namespace Spry {
  7. public errordomain ComponentError {
  8. INVALID_TYPE,
  9. ELEMENT_NOT_FOUND,
  10. TYPE_NOT_FOUND,
  11. CONFLICTING_ATTRIBUTES,
  12. INVALID_TEMPLATE,
  13. INVALID_CONTEXT;
  14. }
  15. public abstract class Component : Object, Renderable {
  16. private static Dictionary<Type, ComponentTemplate> templates;
  17. private static Mutex templates_lock = Mutex();
  18. public string instance_id { get; internal set; default = Uuid.string_random(); }
  19. public abstract string markup { get; }
  20. public virtual StatusCode get_status() {
  21. return StatusCode.OK;
  22. }
  23. public virtual async void prepare() throws Error {
  24. // No-op default
  25. }
  26. public virtual async void prepare_once() throws Error {
  27. // No-op default
  28. }
  29. public virtual async void handle_action(string action) throws Error {
  30. // No-op default
  31. }
  32. public virtual async void continuation(ContinuationContext continuation_context) throws Error {
  33. // No-op default
  34. }
  35. public virtual async void continuation_canceled() throws Error {
  36. // No-op default
  37. }
  38. private PathProvider _path_provider = inject<PathProvider>();
  39. private ContinuationProvider _continuation_provider = inject<ContinuationProvider>();
  40. private ComponentFactory _component_factory = inject<ComponentFactory>();
  41. private CryptographyProvider _cryptography_provider = inject<CryptographyProvider>();
  42. private Catalogue<string, Renderable> _children = new Catalogue<string, Renderable>();
  43. private Dictionary<string, Component> _child_components = new Dictionary<string, Component>();
  44. private HashSet<Component> _global_sources = new HashSet<Component>();
  45. private HashSet<string> _context_properties = new HashSet<string>();
  46. private MarkupDocument _instance;
  47. private bool _prepare_once_called;
  48. private MarkupDocument instance { get {
  49. if(_instance == null) {
  50. try {
  51. lock(_instance) {
  52. if(_instance == null) {
  53. templates_lock.lock ();
  54. if(templates == null) {
  55. templates = new Dictionary<Type, ComponentTemplate>();
  56. }
  57. var type = this.get_type();
  58. ComponentTemplate template;
  59. if(!templates.try_get(type, out template)) {
  60. template = new ComponentTemplate(markup);
  61. templates[type] = template;
  62. }
  63. templates_lock.unlock();
  64. _instance = template.new_instance();
  65. }
  66. }
  67. }
  68. catch (Error e) {
  69. error(e.message);
  70. }
  71. }
  72. return _instance;
  73. }}
  74. protected Enumerable<MarkupNode> get_elements_by_class_name(string class_name) {
  75. return instance.get_elements_by_class_name(class_name);
  76. }
  77. protected Enumerable<MarkupNode> get_elements_by_tag_name(string tag_name) {
  78. return instance.get_elements_by_tag_name(tag_name);
  79. }
  80. protected new Enumerable<MarkupNode> query(string xpath) {
  81. return instance.select(xpath);
  82. }
  83. protected MarkupNode? query_one(string xpath) {
  84. return instance.select_one(xpath);
  85. }
  86. protected MarkupNode get_element_by_global_id(string global_id) {
  87. return instance.get_element_by_id(global_id);
  88. }
  89. protected new MarkupNode? @get(string spry_id) {
  90. return instance.select_one(@"//*[@sid='$(spry_id)']");
  91. }
  92. protected void add_outlet_child(string outlet_id, Renderable renderable) {
  93. _children.add(outlet_id, renderable);
  94. }
  95. protected void add_outlet_children(string outlet_id, Enumerable<Renderable> renderables) {
  96. _children.add_all(outlet_id, renderables);
  97. }
  98. protected void set_outlet_children(string outlet_id, Enumerable<Renderable> renderables) {
  99. _children[outlet_id] = renderables;
  100. }
  101. protected void set_outlet_child(string outlet_id, Renderable renderable) {
  102. _children[outlet_id] = Iterate.single(renderable);
  103. }
  104. protected void clear_outlet_children(string outlet_id) {
  105. _children.clear_key(outlet_id);
  106. }
  107. protected void add_globals_from(Component component) {
  108. _global_sources.add(component);
  109. }
  110. protected T get_component_child<T>(string sid) throws Error {
  111. var node = query_one(@"//spry-component[@sid='$sid']");
  112. if(node == null) {
  113. throw new ComponentError.ELEMENT_NOT_FOUND(@"No spry-component element with sid '$sid' found.");
  114. }
  115. var component = get_component_instance_from_component_node(node);
  116. if(!component.get_type().is_a(typeof(T))) {
  117. throw new ComponentError.INVALID_TYPE(@"Component type $(component.get_type().name()) is not a $(typeof(T).name())");
  118. }
  119. return component;
  120. }
  121. public async MarkupDocument to_document() throws Error {
  122. if(!_prepare_once_called) {
  123. yield prepare_once();
  124. _prepare_once_called = true;
  125. }
  126. yield prepare();
  127. var final_instance = instance.copy();
  128. yield transform_document(final_instance);
  129. return final_instance;
  130. }
  131. internal async MarkupDocument get_dynamic_section(string name) throws Error {
  132. if(!_prepare_once_called) {
  133. yield prepare_once();
  134. _prepare_once_called = true;
  135. }
  136. yield prepare();
  137. // Extract the dynamic fragment
  138. var final_instance = instance.copy();
  139. var template_fragment = final_instance.select_one(@"//*[@spry-dynamic='$name']")?.outer_html;
  140. if(template_fragment == null) {
  141. throw new ComponentError.ELEMENT_NOT_FOUND(@"Could not find spry-dynamic section '$name'.");
  142. }
  143. final_instance.body.inner_html = template_fragment;
  144. // Do regular transform
  145. yield transform_document(final_instance);
  146. return final_instance;
  147. }
  148. public async MarkupDocument get_globals_document() throws Error {
  149. if(!_prepare_once_called) {
  150. yield prepare_once();
  151. _prepare_once_called = true;
  152. }
  153. yield prepare();
  154. var final_instance = instance.copy();
  155. yield transform_document(final_instance);
  156. // Extract out globals
  157. var globals = final_instance.select("//[@spry-global]");
  158. var globals_document = new MarkupDocument();
  159. globals_document.body.append_nodes(globals);
  160. return globals_document;
  161. }
  162. public async HttpResult to_result() throws Error {
  163. var document = yield to_document();
  164. return document.to_result(get_status());
  165. }
  166. private class ComponentTemplate : MarkupTemplate {
  167. private string _markup;
  168. protected override string markup { get { return _markup; } }
  169. public ComponentTemplate(string markup) {
  170. this._markup = markup;
  171. }
  172. }
  173. private Component get_component_instance_from_component_node(MarkupNode node) throws Error {
  174. Component component;
  175. // If no SID, create one to keep track of the instance
  176. var sid = node.get_attribute("sid");
  177. if(sid == null) {
  178. sid = Uuid.string_random();
  179. node.set_attribute("sid", sid);
  180. }
  181. if(!_child_components.try_get(sid, out component)) {
  182. component = _component_factory.create_by_name(node.get_attribute("name"));
  183. _child_components[sid] = component;
  184. }
  185. return component;
  186. }
  187. private async void transform_document(MarkupDocument doc) throws Error {
  188. transform_unique_attributes(doc); // Done first so as to ensure the tracking numbers don't change
  189. transform_if_attributes(doc); // Outputs spry-hidden attributes
  190. remove_hidden_blocks(doc); // Removes tags with spry-hidden attributes
  191. yield transform_per_attributes(doc); // Executes spry-per-* loops, which handles nested expression attributes
  192. yield transform_expression_attributes(doc); // Evaluares *-expr attributes
  193. yield transform_outlets(doc);
  194. yield transform_components(doc);
  195. transform_context_nodes(doc);
  196. transform_action_nodes(doc);
  197. transform_target_nodes(doc);
  198. transform_global_nodes(doc);
  199. transform_resource_nodes(doc);
  200. transform_dynamic_attributes(doc);
  201. transform_continuation_nodes(doc);
  202. remove_internal_sids(doc);
  203. yield append_globals(doc);
  204. }
  205. private async void transform_outlets(MarkupDocument doc) throws Error {
  206. var outlets = doc.select("//spry-outlet");
  207. foreach (var outlet in outlets) {
  208. var nodes = new Series<MarkupNode>();
  209. if(!outlet.has_attribute("sid")) {
  210. throw new ComponentError.INVALID_TEMPLATE("Tag spry-outlet must either define a content-expr or an sid");
  211. }
  212. foreach(var renderable in _children.get_or_empty(outlet.get_attribute("sid"))) {
  213. var document = yield renderable.to_document();
  214. nodes.add_all(document.body.children);
  215. }
  216. outlet.replace_with_nodes(nodes);
  217. }
  218. }
  219. private void remove_hidden_blocks(MarkupDocument doc) {
  220. doc.select("//*[@spry-hidden]")
  221. .iterate(n => n.remove());
  222. }
  223. private void transform_action_nodes(MarkupDocument doc) throws Error {
  224. var action_nodes = doc.select("//*[@spry-action]");
  225. foreach(var node in action_nodes) {
  226. var action = node.get_attribute("spry-action").split(":", 2);
  227. var component_name = action[0].replace(".", "");
  228. if(component_name == "") {
  229. component_name = this.get_type().name();
  230. }
  231. var component_action = action[1];
  232. node.remove_attribute("spry-action");
  233. if(component_name == this.get_type().name() && _context_properties.length > 0) {
  234. var data = new PropertyDictionary();
  235. var root = new PropertyDictionary();
  236. root["this"] = new NativeElement<Component>(this);
  237. var evaluation_context = new EvaluationContext(root);
  238. foreach(var prop_name in _context_properties) {
  239. data[prop_name] = ExpressionParser.parse(@"this.$(prop_name)").evaluate(evaluation_context);
  240. }
  241. var context = new ComponentContext() {
  242. type_name = this.get_type().name(),
  243. timestamp = new DateTime.now_utc(),
  244. instance_id = instance_id,
  245. data = data
  246. };
  247. var context_blob = _cryptography_provider.author_component_context_blob(context);
  248. node.set_attribute("hx-get", _path_provider.get_action_path_with_context(component_name, component_action, context_blob));
  249. }
  250. else {
  251. node.set_attribute("hx-get", _path_provider.get_action_path(component_name, component_action));
  252. }
  253. }
  254. }
  255. private void transform_target_nodes(MarkupDocument doc) {
  256. var target_nodes = doc.select("//*[@spry-target]");
  257. foreach(var node in target_nodes) {
  258. var target_node = doc.select_one(@"//*[@sid='$(node.get_attribute("spry-target"))']");
  259. if(target_node.id == null) {
  260. target_node.id = "_spry-target-" + Uuid.string_random();
  261. }
  262. node.set_attribute("hx-target", @"#$(target_node.id)");
  263. node.remove_attribute("spry-target");
  264. }
  265. }
  266. private void transform_global_nodes(MarkupDocument doc) {
  267. var global_nodes = doc.select("//*[@spry-global]");
  268. foreach(var node in global_nodes) {
  269. var key = node.get_attribute("spry-global");
  270. node.set_attribute("hx-swap-oob", @"[spry-global=\"$key\"]");
  271. }
  272. }
  273. private void transform_resource_nodes(MarkupDocument doc) {
  274. var script_nodes = doc.select("//*[@spry-res]");
  275. foreach(var node in script_nodes) {
  276. var res = node.get_attribute("spry-res");
  277. if(res == null) {
  278. throw new ComponentError.INVALID_TEMPLATE("Attribute spry-res must have a value");
  279. }
  280. node.remove_attribute("spry-res");
  281. var path = "/_spry/res/" + res;
  282. if(node.tag_name == "script" || node.tag_name == "img") {
  283. node.set_attribute("src", path);
  284. continue;
  285. }
  286. if(node.tag_name == "link" && node.get_attribute("rel") == "stylesheet") {
  287. node.set_attribute("href", path);
  288. continue;
  289. }
  290. }
  291. }
  292. private void transform_continuation_nodes(MarkupDocument doc) {
  293. var continuation_nodes = doc.select("//*[@spry-continuation]");
  294. foreach(var node in continuation_nodes) {
  295. var path = _continuation_provider.get_continuation_path(this);
  296. node.set_attribute("hx-ext", "sse");
  297. node.set_attribute("sse-connect", path);
  298. node.set_attribute("sse-close", "_spry-close");
  299. node.remove_attribute("spry-continuation");
  300. }
  301. }
  302. private void remove_internal_sids(MarkupDocument doc) {
  303. doc.select("//*[@sid]")
  304. .iterate(n => n.remove_attribute("sid"));
  305. }
  306. private async void append_globals(MarkupDocument doc) throws Error {
  307. foreach(var source in _global_sources) {
  308. var globals = yield source.get_globals_document();
  309. doc.body.append_nodes(globals.body.children);
  310. }
  311. }
  312. private async void transform_components(MarkupDocument doc) throws Error {
  313. var components = doc.select("//spry-component");
  314. foreach (var component_node in components) {
  315. var component = get_component_instance_from_component_node(component_node);
  316. var document = yield component.to_document();
  317. component_node.replace_with_nodes(document.body.children);
  318. }
  319. }
  320. private void transform_dynamic_attributes(MarkupDocument doc) throws Error {
  321. var nodes = doc.select("//*[@spry-dynamic]");
  322. foreach (var node in nodes) {
  323. var name = node.get_attribute("spry-dynamic");
  324. MarkupNode parent = node;
  325. while((parent = parent.parent) != null) {
  326. if(parent.has_attribute("spry-continuation")) {
  327. break;
  328. }
  329. }
  330. if(parent == null) {
  331. throw new ComponentError.INVALID_TEMPLATE("A tag with a spry-dynamic attribute must be the child of a tag with a spry-continuation attribute");
  332. }
  333. node.set_attribute("sse-swap", @"_spry-dynamic-$name");
  334. node.set_attribute("hx-swap", "outerHTML");
  335. if(!node.has_attribute("id")) {
  336. node.set_attribute("id", @"_spry-dynamic-$name-$instance_id");
  337. }
  338. node.remove_attribute("spry-dynamic");
  339. }
  340. }
  341. private void transform_unique_attributes(MarkupDocument doc) throws Error {
  342. var nodes = doc.select("//*[@spry-unique]");
  343. var counter = 1000;
  344. foreach (var node in nodes) {
  345. if(node.has_attribute("id")) {
  346. throw new ComponentError.INVALID_TEMPLATE("Cannot specify id attribute for an element with a spry-unique attribute");
  347. }
  348. 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-")))) {
  349. 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");
  350. }
  351. node.set_attribute("id", @"_spry-unique-$counter-$instance_id");
  352. counter++;
  353. }
  354. }
  355. private void transform_if_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
  356. var root = new PropertyDictionary();
  357. root["this"] = new NativeElement<Component>(this);
  358. var evaluation_context = context ?? new EvaluationContext(root);
  359. MarkupNode node;
  360. // Select one by one, so we don't have problems with nesting
  361. while((node = doc.select_one("//*[@spry-if or @spry-else-if or @spry-else]")) != null) {
  362. print(@"node $(node.tag_name)\n");
  363. var expression_string = node.get_attribute("spry-if") ?? node.get_attribute("spry-else-if");
  364. node.remove_attribute("spry-if");
  365. node.remove_attribute("spry-else-if");
  366. node.remove_attribute("spry-else");
  367. if(expression_string == null) {
  368. print("null\n");
  369. // else case
  370. continue;
  371. }
  372. var result = evaluate_if_expression(evaluation_context, expression_string, node);
  373. print(@"Result $(result)\n");
  374. if(result) {
  375. // Hide any chained nodes
  376. MarkupNode chained_node;
  377. while((chained_node = node.next_element_sibling) != null) {
  378. if(chained_node.has_attribute("spry-else") || chained_node.get_attribute("spry-else-if") != null) {
  379. chained_node.remove();
  380. }
  381. else {
  382. // If a sibling has no spry-else or spry-else-if it breaks the chain
  383. break;
  384. }
  385. }
  386. }
  387. else {
  388. // Mark this node for removal when condition is false
  389. node.remove();
  390. }
  391. }
  392. }
  393. private bool evaluate_if_expression(EvaluationContext context, string expression_string, MarkupNode node) throws Error {
  394. var expression = ExpressionParser.parse(expression_string);
  395. var result = expression.evaluate(context);
  396. bool boolean_result;
  397. if(result.is<bool?>()) {
  398. boolean_result = result.as<bool?>();
  399. }
  400. else if(result.is<int>()) {
  401. boolean_result = result.as<int>() != 0;
  402. }
  403. else {
  404. boolean_result = !result.is_null();
  405. }
  406. return boolean_result;
  407. }
  408. private async void transform_per_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
  409. MarkupNode node;
  410. // Select one by one, so we don't have problems with nesting
  411. while((node = doc.nodes.first_or_default(n => n.get_attributes().any(a => a.key.has_prefix("spry-per-")))) != null) {
  412. var attribute = node.get_attributes().first(s => s.key.has_prefix("spry-per-"));
  413. var root = new PropertyDictionary();
  414. root["this"] = new NativeElement<Component>(this);
  415. var evaluation_context = context ?? new EvaluationContext(root);
  416. var expression = ExpressionParser.parse(attribute.value);
  417. var result = expression.evaluate(evaluation_context);
  418. if(!result.assignable_to<Enumerable>()) {
  419. 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())");
  420. }
  421. node.remove_attribute(attribute.key);
  422. var values = result.as<Enumerable>().to_elements();
  423. var output_nodes = new Series<MarkupNode>();
  424. foreach(var value in values) {
  425. evaluation_context.root_values[attribute.key[9:]] = value;
  426. var fragment = new MarkupDocument();
  427. fragment.body.append_node(node);
  428. // Basic transform pipeline for fragment, the rest will get processed as part
  429. // of the wider component document later.
  430. transform_if_attributes(fragment, evaluation_context);
  431. remove_hidden_blocks(fragment);
  432. yield transform_per_attributes(fragment, evaluation_context);
  433. yield transform_expression_attributes(fragment, evaluation_context);
  434. output_nodes.add_all(fragment.body.children);
  435. }
  436. node.replace_with_nodes(output_nodes);
  437. }
  438. }
  439. private void transform_context_nodes(MarkupDocument doc) throws Error {
  440. var nodes = doc.select("//spry-context"); // Can't check for suffixes with xpath so iterate all nodes with attributes
  441. foreach (var node in nodes) {
  442. var property_name = node.get_attribute("property");
  443. if(property_name == null) {
  444. throw new ComponentError.INVALID_TEMPLATE("Tag spry-context must have a property attribute");
  445. }
  446. _context_properties.add(property_name);
  447. node.remove();
  448. }
  449. }
  450. private async void transform_expression_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
  451. var nodes = doc.select("//*[@*]"); // Can't check for suffixes with xpath so iterate all nodes with attributes
  452. foreach (var node in nodes) {
  453. var attributes = node.get_attributes();
  454. foreach (var attribute in attributes) {
  455. if(!attribute.key.has_suffix("-expr")) {
  456. continue;
  457. }
  458. var real_attribute = attribute.key.substring(0, attribute.key.length - 5);
  459. var root = new PropertyDictionary();
  460. root["this"] = new NativeElement<Component>(this);
  461. var evaluation_context = context ?? new EvaluationContext(root);
  462. var expression = ExpressionParser.parse(attribute.value);
  463. var result = expression.evaluate(evaluation_context);
  464. node.remove_attribute(attribute.key);
  465. // class.* can be boolean
  466. if(result.type().is_a(typeof(bool)) && real_attribute.has_prefix("class-")) {
  467. var class_name = real_attribute.split("-", 2)[1];
  468. if(result.as<bool>()) {
  469. if(!node.has_class(class_name)) {
  470. node.add_class(class_name);
  471. }
  472. }
  473. else {
  474. node.remove_class(class_name);
  475. }
  476. continue;
  477. }
  478. if(real_attribute == "content" && result.type().is_a(typeof(Renderable))) {
  479. var renderable = result.as<Renderable>();
  480. var document = yield renderable.to_document();
  481. if(node.tag_name == "spry-outlet") {
  482. if(node.get_attribute("sid") != null) {
  483. throw new ComponentError.CONFLICTING_ATTRIBUTES("Tag 'spry-outlet' cannot have both a 'content-expr' and 'sid' attribute");
  484. }
  485. node.replace_with_nodes(document.body.children);
  486. }
  487. else {
  488. node.clear_children();
  489. node.append_nodes(document.body.children);
  490. }
  491. continue;
  492. }
  493. // everything else read as string
  494. var str_value = result.as<string>();
  495. if(real_attribute == "content") {
  496. node.text_content = str_value;
  497. continue;
  498. }
  499. if(real_attribute.has_prefix("style-")) {
  500. var style_name = real_attribute.split("-", 2)[1];
  501. node.set_style(style_name, str_value);
  502. continue;
  503. }
  504. node.set_attribute(real_attribute, str_value);
  505. }
  506. }
  507. }
  508. private bool has_any_parent_where(MarkupNode node, PredicateDelegate<MarkupNode> predicate) {
  509. var current = node;
  510. while((current = current.parent) != null) {
  511. if(predicate(current)) {
  512. return true;
  513. }
  514. }
  515. return false;
  516. }
  517. }
  518. }