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