1 /++ 2 This provides a kind of web template support, built on top of [arsd.dom] and [arsd.script], in support of [arsd.cgi]. 3 4 ```html 5 <main body-class="foo"> 6 <%=HTML some_var_with_html %> 7 <%= some_var %> 8 9 <if-true cond="whatever"> 10 whatever == true 11 </if-true> 12 <or-else> 13 whatever == false 14 </or-else> 15 16 <for-each over="some_array" as="item" index="idx"> 17 <%= item %> 18 </for-each> 19 <or-else> 20 there were no items. 21 </or-else> 22 23 <form> 24 <!-- new on July 17, 2021 (dub v10.3) --> 25 <hidden-form-data from="data_var" name="arg_name" /> 26 </form> 27 28 <render-template file="partial.html" /> 29 30 <document-fragment></document-fragment> 31 32 <script> 33 var a = <%= some_var %>; // it will be json encoded in a script tag, so it can be safely used from Javascript 34 </script> 35 </main> 36 ``` 37 38 Functions available: 39 `encodeURIComponent`, `formatDate`, `dayOfWeek`, `formatTime`, `filterKeys` 40 41 History: 42 Things inside script tag were added on January 7, 2022. 43 44 This module was added to dub on September 11, 2023 (dub v11.2). 45 46 It was originally written in July 2019 to support a demonstration of moving a ruby on rails app to D. 47 +/ 48 module arsd.webtemplate; 49 50 // FIXME: make script exceptions show line from the template it was in too 51 52 import arsd.script; 53 import arsd.dom; 54 55 public import arsd.jsvar : var; 56 57 /++ 58 A class to render web template files into HTML documents. 59 60 61 You can customize various parts of this with subclassing and dependency injection. Customization hook points include: 62 63 $(NUMBERED_LIST 64 * You pass a [TemplateLoader] instance to the constructor. This object is responsible for loading a particular 65 named template and returning a string of its html text. If you don't pass one, the default behavior is to load a 66 particular file out of the templates directory. 67 68 * The next step is transforming the string the TemplateLoader returned into a document object model. This is done 69 by a private function at this time. If you want to use a different format than HTML, you should either embed the other 70 language in your template (you can pass a translator to the constructor, details to follow later in this document) 71 72 * Next, the contexts must be prepared. It will call [addDefaultFunctions] on each one to prepare them. You can override that 73 to provide more or fewer functions. 74 75 * Now, it is time to expand the template. This is done by a private function, so you cannot replace this step, but you can 76 customize it in some ways by passing functions to the constructor's `embeddedTagTranslators` argument. 77 78 * At this point, it combines the expanded template with the skeleton to form the complete, expanded document. 79 80 * Finally, it will call your custom post-processing function right before returning the document. You can override the [postProcess] method to add custom behavior to this step. 81 ) 82 83 ### Custom Special Tags 84 85 You can define translator for special tags, such as to embed a block of custom markup inside your template. 86 87 Let's suppose we want to add a `<plaintext>...</plaintext>` tag that does not need HTML entity encoding. 88 89 ```html 90 <main> 91 I can use <b>HTML</b> & need to respect entity encoding here. 92 93 <plaintext> 94 But here, I can write & as plain text and <b>html</b> will not work. 95 </plaintext> 96 </main> 97 ``` 98 99 We can make that possible by defining a custom special tag when constructing the `WebTemplateRenderer`, like this: 100 101 --- 102 auto renderer = new WebTemplateRenderer(null /* no special loader needed */, [ 103 // this argument is the special tag name and a function to work with it 104 // listed as associative arrays. 105 "plaintext": function(string content, string[string] attributes) { 106 import arsd.dom; 107 return WebTemplateRenderer.EmbeddedTagResult(new TextNode(content)); 108 } 109 ]); 110 --- 111 112 The associative array keys are the special tag name. For each one, this instructs the HTML parser to treat them similarly to `<script>` - it will read until the closing tag, making no attempt to parse anything else inside it. It just scoops of the content, then calls your function to decide what to do with it. 113 114 $(SIDEBAR 115 Note: just like with how you cannot use `"</script>"` in a Javascript block in HTML, you also need to avoid using the closing tag as a string in your custom thing! 116 ) 117 118 Your function is given an associative array of attributes on the special tag and its inner content, as raw source, from the file. You must construct an appropriate DOM element from the content (including possibly a `DocumentFragment` object if you need multiple tags inside) and return it, along with, optionally, an enumerated value telling the renderer if it should try to expand template text inside this new element. If you don't provide a value, it will try to automatically guess what it should do based on the returned element type. (That is, if you return a text node, it will try to do a string-based replacement, and if you return another node, it will descend into it the same as any other node written in the document looking for `AspCode` elements.) 119 120 The example given here returns a `TextNode`, so we let it do the default string-based template content processing. But if we returned `WebTemplateRenderer.EmbeddedTagResult(new TextNode(content), false);`, it would not support embedded templates and any `<% .. %>` stuff would be left as-is. 121 122 $(TIP 123 You can trim some of that namespace spam if you make a subclass and pass it to `super` inside your constructor. 124 ) 125 126 History: 127 Added February 5, 2024 (dub v11.5) 128 +/ 129 class WebTemplateRenderer { 130 private TemplateLoader loader; 131 private EmbeddedTagResult function(string content, string[string] attributes)[string] embeddedTagTranslators; 132 133 /++ 134 135 +/ 136 this(TemplateLoader loader = null, EmbeddedTagResult function(string content, string[string] attributes)[string] embeddedTagTranslators = null) { 137 if(loader is null) 138 loader = TemplateLoader.forDirectory("templates/"); 139 this.loader = loader; 140 this.embeddedTagTranslators = embeddedTagTranslators; 141 } 142 143 /++ 144 145 +/ 146 struct EmbeddedTagResult { 147 Element element; 148 bool scanForTemplateContent = true; 149 } 150 151 /++ 152 153 +/ 154 final Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null) { 155 import arsd.cgi; 156 157 try { 158 addDefaultFunctions(context); 159 addDefaultFunctions(skeletonContext); 160 161 if(skeletonName.length == 0) 162 skeletonName = "skeleton.html"; 163 164 auto skeleton = parseTemplateString(loader.loadTemplateHtml(skeletonName), WrapTemplateIn.nothing); 165 auto document = parseTemplateString(loader.loadTemplateHtml(templateName), WrapTemplateIn.rootElement); 166 167 expandTemplate(skeleton.root, skeletonContext); 168 169 foreach(nav; skeleton.querySelectorAll("nav[data-relative-to]")) { 170 auto r = nav.getAttribute("data-relative-to"); 171 foreach(a; nav.querySelectorAll("a")) { 172 a.attrs.href = Uri(a.attrs.href).basedOn(Uri(r));// ~ a.attrs.href; 173 } 174 } 175 176 expandTemplate(document.root, context); 177 178 // also do other unique elements and move them over. 179 // and have some kind of <document-fragment> that can be just reduced when going out in the final result. 180 181 // and try partials. 182 183 auto templateMain = document.requireSelector(":root > main"); 184 if(templateMain.hasAttribute("body-class")) { 185 skeleton.requireSelector("body").addClass(templateMain.getAttribute("body-class")); 186 templateMain.removeAttribute("body-class"); 187 } 188 189 skeleton.requireSelector("main").replaceWith(templateMain.removeFromTree); 190 191 if(auto title = document.querySelector(":root > title")) 192 skeleton.requireSelector(":root > head > title").innerHTML = title.innerHTML; 193 194 // also allow top-level unique id replacements 195 foreach(item; document.querySelectorAll(":root > [id]")) 196 skeleton.requireElementById(item.id).replaceWith(item.removeFromTree); 197 198 foreach(df; skeleton.querySelectorAll("document-fragment")) 199 df.stripOut(); 200 201 debug 202 skeleton.root.prependChild(new HtmlComment(null, templateName ~ " inside skeleton.html")); 203 204 postProcess(skeleton); 205 206 return skeleton; 207 } catch(Exception e) { 208 throw new TemplateException(templateName, context, e); 209 //throw e; 210 } 211 } 212 213 private Document parseTemplateString(string templateHtml, WrapTemplateIn wrapTemplateIn) { 214 auto document = new Document(); 215 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 216 final switch(wrapTemplateIn) { 217 case WrapTemplateIn.nothing: 218 // no change needed 219 break; 220 case WrapTemplateIn.rootElement: 221 templateHtml = "<root>" ~ templateHtml ~ "</root>"; 222 break; 223 } 224 foreach(k, v; embeddedTagTranslators) 225 document.rawSourceElements ~= k; 226 document.parse(templateHtml, true, true); 227 return document; 228 } 229 230 private enum WrapTemplateIn { 231 nothing, 232 rootElement 233 } 234 235 /++ 236 Adds the default functions to the context. You can override this to add additional default functions (or static data) to the context objects. 237 +/ 238 void addDefaultFunctions(var context) { 239 import std.conv; 240 // FIXME: I prolly want it to just set the prototype or something 241 242 /+ 243 foo |> filterKeys(["foo", "bar"]); 244 245 It needs to match the filter, then if it is -pattern, it is removed and if it is +pattern, it is retained. 246 247 First one that matches applies to the key, so the last one in the list is your default. 248 249 Default is to reject. Putting a "*" at the end will keep everything not removed though. 250 251 ["-foo", "*"] // keep everything except foo 252 +/ 253 context.filterKeys = function var(var f, string[] filters) { 254 import std.path; 255 var o = var.emptyObject; 256 foreach(k, v; f) { 257 bool keep = false; 258 foreach(filter; filters) { 259 if(filter.length == 0) 260 throw new Exception("invalid filter"); 261 bool filterOff = filter[0] == '-'; 262 if(filterOff) 263 filter = filter[1 .. $]; 264 if(globMatch(k.get!string, filter)) { 265 keep = !filterOff; 266 break; 267 } 268 } 269 if(keep) 270 o[k] = v; 271 } 272 return o; 273 }; 274 275 context.encodeURIComponent = function string(var f) { 276 import arsd.core; 277 return encodeUriComponent(f.get!string); 278 }; 279 280 context.formatDate = function string(string s) { 281 if(s.length < 10) 282 return s; 283 auto year = s[0 .. 4]; 284 auto month = s[5 .. 7]; 285 auto day = s[8 .. 10]; 286 287 return month ~ "/" ~ day ~ "/" ~ year; 288 }; 289 290 context.dayOfWeek = function string(string s) { 291 import std.datetime; 292 return daysOfWeekFullNames[Date.fromISOExtString(s[0 .. 10]).dayOfWeek]; 293 }; 294 295 context.formatTime = function string(string s) { 296 if(s.length < 20) 297 return s; 298 auto hour = s[11 .. 13].to!int; 299 auto minutes = s[14 .. 16].to!int; 300 auto seconds = s[17 .. 19].to!int; 301 302 auto am = (hour >= 12) ? "PM" : "AM"; 303 if(hour > 12) 304 hour -= 12; 305 306 return hour.to!string ~ (minutes < 10 ? ":0" : ":") ~ minutes.to!string ~ " " ~ am; 307 }; 308 309 // don't want checking meta or data to be an error 310 if(context.meta == null) 311 context.meta = var.emptyObject; 312 if(context.data == null) 313 context.data = var.emptyObject; 314 } 315 316 /++ 317 The default is currently to do nothing. This function only exists for you to override it. 318 319 However, this may change in the future. To protect yourself, if you subclass and override 320 this method, always call `super.postProcess(document);` before doing your own customizations. 321 +/ 322 void postProcess(Document document) { 323 324 } 325 326 private void expandTemplate(Element root, var context) { 327 import std.string; 328 329 string replaceThingInString(string v) { 330 auto idx = v.indexOf("<%="); 331 if(idx == -1) 332 return v; 333 auto n = v[0 .. idx]; 334 auto r = v[idx + "<%=".length .. $]; 335 336 auto end = r.indexOf("%>"); 337 if(end == -1) 338 throw new Exception("unclosed asp code in attribute"); 339 auto code = r[0 .. end]; 340 r = r[end + "%>".length .. $]; 341 342 import arsd.script; 343 auto res = interpret(code, context).get!string; 344 345 return n ~ res ~ replaceThingInString(r); 346 } 347 348 foreach(k, v; root.attributes) { 349 if(k == "onrender") { 350 continue; 351 } 352 353 v = replaceThingInString(v); 354 355 root.setAttribute(k, v); 356 } 357 358 bool lastBoolResult; 359 360 foreach(ele; root.children) { 361 if(ele.tagName == "if-true") { 362 auto fragment = new DocumentFragment(null); 363 import arsd.script; 364 auto got = interpret(ele.attrs.cond, context).opCast!bool; 365 if(got) { 366 ele.tagName = "root"; 367 expandTemplate(ele, context); 368 fragment.stealChildren(ele); 369 } 370 lastBoolResult = got; 371 ele.replaceWith(fragment); 372 } else if(ele.tagName == "or-else") { 373 auto fragment = new DocumentFragment(null); 374 if(!lastBoolResult) { 375 ele.tagName = "root"; 376 expandTemplate(ele, context); 377 fragment.stealChildren(ele); 378 } 379 ele.replaceWith(fragment); 380 } else if(ele.tagName == "for-each") { 381 auto fragment = new DocumentFragment(null); 382 var nc = var.emptyObject(context); 383 lastBoolResult = false; 384 auto got = interpret(ele.attrs.over, context); 385 foreach(k, item; got) { 386 lastBoolResult = true; 387 nc[ele.attrs.as] = item; 388 if(ele.attrs.index.length) 389 nc[ele.attrs.index] = k; 390 auto clone = ele.cloneNode(true); 391 clone.tagName = "root"; // it certainly isn't a for-each anymore! 392 expandTemplate(clone, nc); 393 394 fragment.stealChildren(clone); 395 } 396 ele.replaceWith(fragment); 397 } else if(ele.tagName == "render-template") { 398 import std.file; 399 auto templateName = ele.getAttribute("file"); 400 auto document = new Document(); 401 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 402 document.parse("<root>" ~ loader.loadTemplateHtml(templateName) ~ "</root>", true, true); 403 404 var obj = var.emptyObject; 405 obj.prototype = context; 406 407 // FIXME: there might be other data you pass from the parent... 408 if(auto data = ele.getAttribute("data")) { 409 obj["data"] = var.fromJson(data); 410 } 411 412 expandTemplate(document.root, obj); 413 414 auto fragment = new DocumentFragment(null); 415 416 debug fragment.appendChild(new HtmlComment(null, templateName)); 417 fragment.stealChildren(document.root); 418 debug fragment.appendChild(new HtmlComment(null, "end " ~ templateName)); 419 420 ele.replaceWith(fragment); 421 } else if(ele.tagName == "hidden-form-data") { 422 auto from = interpret(ele.attrs.from, context); 423 auto name = ele.attrs.name; 424 425 auto form = new Form(null); 426 427 populateForm(form, from, name); 428 429 auto fragment = new DocumentFragment(null); 430 fragment.stealChildren(form); 431 432 ele.replaceWith(fragment); 433 } else if(auto asp = cast(AspCode) ele) { 434 auto code = asp.source[1 .. $-1]; 435 auto fragment = new DocumentFragment(null); 436 if(code[0] == '=') { 437 import arsd.script; 438 if(code.length > 5 && code[1 .. 5] == "HTML") { 439 auto got = interpret(code[5 .. $], context); 440 if(auto native = got.getWno!Element) 441 fragment.appendChild(native); 442 else 443 fragment.innerHTML = got.get!string; 444 } else { 445 auto got = interpret(code[1 .. $], context).get!string; 446 fragment.innerText = got; 447 } 448 } 449 asp.replaceWith(fragment); 450 } else if(ele.tagName == "script") { 451 auto source = ele.innerHTML; 452 string newCode; 453 check_more: 454 auto idx = source.indexOf("<%="); 455 if(idx != -1) { 456 newCode ~= source[0 .. idx]; 457 auto remaining = source[idx + 3 .. $]; 458 idx = remaining.indexOf("%>"); 459 if(idx == -1) 460 throw new Exception("unclosed asp code in script"); 461 auto code = remaining[0 .. idx]; 462 463 auto data = interpret(code, context); 464 newCode ~= data.toJson(); 465 466 source = remaining[idx + 2 .. $]; 467 goto check_more; 468 } 469 470 if(newCode is null) 471 {} // nothing needed 472 else { 473 newCode ~= source; 474 ele.innerRawSource = newCode; 475 } 476 } else if(auto pTranslator = ele.tagName in embeddedTagTranslators) { 477 auto replacement = (*pTranslator)(ele.innerHTML, ele.attributes); 478 if(replacement.element is null) 479 ele.stripOut(); 480 else { 481 ele.replaceWith(replacement.element); 482 if(replacement.scanForTemplateContent) { 483 if(auto tn = cast(TextNode) replacement.element) 484 tn.textContent = replaceThingInString(tn.nodeValue); 485 else 486 expandTemplate(replacement.element, context); 487 } 488 } 489 } else { 490 expandTemplate(ele, context); 491 } 492 } 493 494 if(root.hasAttribute("onrender")) { 495 var nc = var.emptyObject(context); 496 nc["this"] = wrapNativeObject(root); 497 nc["this"]["populateFrom"] = delegate var(var this_, var[] args) { 498 auto form = cast(Form) root; 499 if(form is null) return this_; 500 foreach(k, v; args[0]) { 501 populateForm(form, v, k.get!string); 502 } 503 return this_; 504 }; 505 interpret(root.getAttribute("onrender"), nc); 506 507 root.removeAttribute("onrender"); 508 } 509 } 510 } 511 512 /+ 513 unittest { 514 515 } 516 +/ 517 518 deprecated("Use a WebTemplateRenderer class instead") 519 void addDefaultFunctions(var context) { 520 scope renderer = new WebTemplateRenderer(null); 521 renderer.addDefaultFunctions(context); 522 } 523 524 525 // FIXME: want to show additional info from the exception, neatly integrated, whenever possible. 526 class TemplateException : Exception { 527 string templateName; 528 var context; 529 Exception e; 530 this(string templateName, var context, Exception e) { 531 this.templateName = templateName; 532 this.context = context; 533 this.e = e; 534 535 super("Exception in template " ~ templateName ~ ": " ~ e.msg); 536 } 537 } 538 539 /++ 540 A loader object for reading raw template, so you can use something other than files if you like. 541 542 See [TemplateLoader.forDirectory] to a pre-packaged class that implements a loader for a particular directory. 543 544 History: 545 Added December 11, 2023 (dub v11.3) 546 +/ 547 interface TemplateLoader { 548 /++ 549 This is the main method to look up a template name and return its HTML as a string. 550 551 Typical implementation is to just `return std.file.readText(directory ~ name);` 552 +/ 553 string loadTemplateHtml(string name); 554 555 /++ 556 Returns a loader for files in the given directory. 557 +/ 558 static TemplateLoader forDirectory(string directoryName) { 559 if(directoryName.length && directoryName[$-1] != '/') 560 directoryName ~= "/"; 561 562 return new class TemplateLoader { 563 string loadTemplateHtml(string name) { 564 import std.file; 565 return readText(directoryName ~ name); 566 } 567 }; 568 } 569 } 570 571 /++ 572 Loads a template from the template directory, applies the given context variables, and returns the html document in dom format. You can use [Document.toString] to make a string. 573 574 Parameters: 575 templateName = the name of the main template to load. This is usually a .html filename in the `templates` directory (but see also the `loader` param) 576 context = the global object available to scripts inside the template 577 skeletonContext = the global object available to the skeleton template 578 skeletonName = the name of the skeleton template to load. This is usually a .html filename in the `templates` directory (but see also the `loader` param), and the skeleton file has the boilerplate html and defines placeholders for the main template 579 loader = a class that defines how to load templates by name. If you pass `null`, it uses a default implementation that loads files from the `templates/` directory. 580 581 History: 582 Parameter `loader` was added on December 11, 2023 (dub v11.3) 583 584 See_Also: 585 [WebTemplateRenderer] gives you more control than the argument list here provides. 586 +/ 587 Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null, TemplateLoader loader = null) { 588 scope auto renderer = new WebTemplateRenderer(loader); 589 return renderer.renderTemplate(templateName, context, skeletonContext, skeletonName); 590 } 591 592 /++ 593 Shows how top-level things from the template are moved to their corresponding items on the skeleton. 594 +/ 595 unittest { 596 // for the unittest, we want to inject a loader that uses plain strings instead of files. 597 auto testLoader = new class TemplateLoader { 598 string loadTemplateHtml(string name) { 599 switch(name) { 600 case "skeleton": 601 return ` 602 <html> 603 <head> 604 <!-- you can define replaceable things with ids --> 605 <!-- including <document-fragment>s which are stripped out when the template is finalized --> 606 <document-fragment id="header-stuff" /> 607 </head> 608 <body> 609 <main></main> 610 </body> 611 </html> 612 `; 613 case "main": 614 return ` 615 <main>Hello</main> 616 <document-fragment id="header-stuff"> 617 <title>My title</title> 618 </document-fragment> 619 `; 620 default: assert(0); 621 } 622 } 623 }; 624 625 Document doc = renderTemplate("main", var.emptyObject, var.emptyObject, "skeleton", testLoader); 626 627 assert(doc.querySelector("document-fragment") is null); // the <document-fragment> items are stripped out 628 assert(doc.querySelector("title") !is null); // but the stuff from inside it is brought in 629 assert(doc.requireSelector("main").textContent == "Hello"); // and the main from the template is moved to the skeelton 630 } 631 632 void populateForm(Form form, var obj, string name) { 633 import std.string; 634 635 if(obj.payloadType == var.Type.Object) { 636 form.setValue(name, ""); 637 foreach(k, v; obj) { 638 auto fn = name.replace("%", k.get!string); 639 // should I unify structs and assoctiavite arrays? 640 populateForm(form, v, fn ~ "["~k.get!string~"]"); 641 //populateForm(form, v, fn ~"."~k.get!string); 642 } 643 } else { 644 //import std.stdio; writeln("SET ", name, " ", obj, " ", obj.payloadType); 645 form.setValue(name, obj.get!string); 646 } 647 648 } 649 650 /++ 651 Replaces `things[0]` with `things[1]` in `what` all at once. 652 Returns the new string. 653 654 History: 655 Added February 12, 2022. I might move it later. 656 +/ 657 string multiReplace(string what, string[] things...) { 658 import std.string; // FIXME: indexOf not actually ideal but meh 659 if(things.length == 0) 660 return what; 661 662 assert(things.length % 2 == 0); 663 664 string n; 665 666 while(what.length) { 667 int nextIndex = cast(int) what.length; 668 int nextThing = -1; 669 670 foreach(i, thing; things) { 671 if(i & 1) 672 continue; 673 674 auto idx = what.indexOf(thing); 675 if(idx != -1 && idx < nextIndex) { 676 nextIndex = cast(int) idx; 677 nextThing = cast(int) i; 678 } 679 } 680 681 if(nextThing == -1) { 682 n ~= what; 683 what = null; 684 } else { 685 n ~= what[0 .. nextIndex]; 686 what = what[nextIndex + things[nextThing].length .. $]; 687 n ~= things[nextThing + 1]; 688 continue; 689 } 690 } 691 692 return n; 693 } 694 695 immutable daysOfWeekFullNames = [ 696 "Sunday", 697 "Monday", 698 "Tuesday", 699 "Wednesday", 700 "Thursday", 701 "Friday", 702 "Saturday" 703 ]; 704 705 /++ 706 UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides default generic element formatting and instead uses the specified template name to render the return value. 707 708 Inside the template, the value returned by the function will be available in the context as the variable `data`. 709 +/ 710 struct Template { 711 string name; 712 } 713 /++ 714 UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides the default template skeleton file name. 715 +/ 716 struct Skeleton { 717 string name; 718 } 719 720 /++ 721 UDA to attach runtime metadata to a function. Will be available in the template. 722 723 History: 724 Added July 12, 2021 725 +/ 726 struct meta { 727 string name; 728 string value; 729 } 730 731 /++ 732 Can be used as a return value from one of your own methods when rendering websites with [WebPresenterWithTemplateSupport]. 733 +/ 734 struct RenderTemplate { 735 this(string name, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null) { 736 this.name = name; 737 this.context = context; 738 this.skeletonContext = skeletonContext; 739 this.skeletonName = skeletonName; 740 } 741 742 string name; 743 var context; 744 var skeletonContext; 745 string skeletonName; 746 } 747 748 749 /++ 750 Make a class that inherits from this with your further customizations, or minimally: 751 --- 752 class MyPresenter : WebPresenterWithTemplateSupport!MyPresenter { } 753 --- 754 +/ 755 template WebPresenterWithTemplateSupport(CTRP) { 756 import arsd.cgi; 757 class WebPresenterWithTemplateSupport : WebPresenter!(CTRP) { 758 override Element htmlContainer() { 759 try { 760 auto skeleton = renderTemplate("generic.html", var.emptyObject, var.emptyObject, "skeleton.html", templateLoader()); 761 return skeleton.requireSelector("main"); 762 } catch(Exception e) { 763 auto document = new Document("<html><body><p>generic.html trouble: <span id=\"ghe\"></span></p> <main></main></body></html>"); 764 document.requireSelector("#ghe").textContent = e.msg; 765 return document.requireSelector("main"); 766 } 767 } 768 769 static struct Meta { 770 typeof(null) at; 771 string templateName; 772 string skeletonName; 773 string[string] meta; 774 Form function(WebPresenterWithTemplateSupport presenter) automaticForm; 775 alias at this; 776 } 777 template methodMeta(alias method) { 778 static Meta helper() { 779 Meta ret; 780 781 // ret.at = typeof(super).methodMeta!method; 782 783 foreach(attr; __traits(getAttributes, method)) 784 static if(is(typeof(attr) == Template)) 785 ret.templateName = attr.name; 786 else static if(is(typeof(attr) == Skeleton)) 787 ret.skeletonName = attr.name; 788 else static if(is(typeof(attr) == .meta)) 789 ret.meta[attr.name] = attr.value; 790 791 ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) { 792 return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null); 793 }; 794 795 return ret; 796 } 797 enum methodMeta = helper(); 798 } 799 800 /// You can override this 801 void addContext(Cgi cgi, var ctx) {} 802 803 /++ 804 You can override this. The default is "templates/". Your returned string must end with '/'. 805 (in future versions it will probably allow a null return too, but right now it must be a /). 806 807 History: 808 Added December 6, 2023 (dub v11.3) 809 +/ 810 TemplateLoader templateLoader() { 811 return null; 812 } 813 814 /++ 815 You can override this. 816 817 History: 818 Added February 5, 2024 (dub v11.5) 819 +/ 820 WebTemplateRenderer webTemplateRenderer() { 821 return new WebTemplateRenderer(templateLoader()); 822 } 823 824 void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) { 825 addContext(cgi, ret.context); 826 827 auto renderer = this.webTemplateRenderer(); 828 829 auto skeleton = renderer.renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName); 830 cgi.setResponseContentType("text/html; charset=utf8"); 831 cgi.gzipResponse = true; 832 cgi.write(skeleton.toString(), true); 833 } 834 835 void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) { 836 if(meta.templateName.length) { 837 var sobj = var.emptyObject; 838 839 var obj = var.emptyObject; 840 841 obj.data = ret; 842 843 /+ 844 sobj.meta = var.emptyObject; 845 foreach(k,v; meta.meta) 846 sobj.meta[k] = v; 847 +/ 848 849 obj.meta = var.emptyObject; 850 foreach(k,v; meta.meta) 851 obj.meta[k] = v; 852 853 obj.meta.currentPath = cgi.pathInfo; 854 obj.meta.automaticForm = { return meta.automaticForm(this).toString; }; 855 856 presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta); 857 } else 858 super.presentSuccessfulReturnAsHtml(cgi, ret, meta); 859 } 860 } 861 } 862 863 WebTemplateRenderer DefaultWtrFactory(TemplateLoader loader) { 864 return new WebTemplateRenderer(loader); 865 } 866 867 /++ 868 Serves up a directory of template files as html. This is meant to be used for some near-static html in the midst of an application, giving you a little bit of dynamic content and conveniences with the ease of editing files without recompiles. 869 870 Parameters: 871 urlPrefix = the url prefix to trigger this handler, relative to the current dispatcher base 872 directory = the directory, under the template directory, to find the template files 873 skeleton = the name of the skeleton file inside the template directory 874 extension = the file extension to add to the url name to get the template name 875 wtrFactory = an alias to a function of type `WebTemplateRenderer function(TemplateLoader loader)` that returns `new WebTemplateRenderer(loader)` (or similar subclasses/argument lists); 876 877 To get the filename of the template from the url, it will: 878 879 1) Strip the url prefixes off to get just the filename 880 881 2) Concatenate the directory with the template directory 882 883 3) Add the extension to the givenname 884 885 $(PITFALL 886 The `templateDirectory` parameter may be removed or changed in the near future. 887 ) 888 889 History: 890 Added July 28, 2021 (documented dub v11.0) 891 892 The `wtrFactory` parameter was added on February 5, 2024 (dub v11.5). 893 +/ 894 auto serveTemplateDirectory(alias wtrFactory = DefaultWtrFactory)(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html", string templateDirectory = "templates/") { 895 import arsd.cgi; 896 import std.file; 897 898 assert(urlPrefix[0] == '/'); 899 assert(urlPrefix[$-1] == '/'); 900 901 assert(templateDirectory[$-1] == '/'); 902 903 static struct DispatcherDetails { 904 string directory; 905 string skeleton; 906 string extension; 907 string templateDirectory; 908 } 909 910 if(directory is null) 911 directory = urlPrefix[1 .. $]; 912 913 if(directory.length == 0) 914 directory = "./"; 915 916 assert(directory[$-1] == '/'); 917 918 static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) { 919 auto file = cgi.pathInfo[urlPrefix.length .. $]; 920 if(file.indexOf("/") != -1 || file.indexOf("\\") != -1) 921 return false; 922 923 auto fn = details.templateDirectory ~ details.directory ~ file ~ details.extension; 924 if(std.file.exists(fn)) { 925 cgi.setResponseExpiresRelative(600, true); // 10 minute cache expiration by default, FIXME it should be configurable 926 927 auto loader = TemplateLoader.forDirectory(details.templateDirectory); 928 929 WebTemplateRenderer renderer = wtrFactory(loader); 930 931 auto doc = renderer.renderTemplate(fn[details.templateDirectory.length.. $], var.emptyObject, var.emptyObject, details.skeleton); 932 cgi.gzipResponse = true; 933 cgi.write(doc.toString, true); 934 return true; 935 } else { 936 return false; 937 } 938 } 939 940 return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension, templateDirectory)); 941 }