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> 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 // FIXME: want to show additional info from the exception, neatly integrated, whenever possible. 58 class TemplateException : Exception { 59 string templateName; 60 var context; 61 Exception e; 62 this(string templateName, var context, Exception e) { 63 this.templateName = templateName; 64 this.context = context; 65 this.e = e; 66 67 super("Exception in template " ~ templateName ~ ": " ~ e.msg); 68 } 69 } 70 71 void addDefaultFunctions(var context) { 72 import std.conv; 73 // FIXME: I prolly want it to just set the prototype or something 74 75 /+ 76 foo |> filterKeys(["foo", "bar"]); 77 78 It needs to match the filter, then if it is -pattern, it is removed and if it is +pattern, it is retained. 79 80 First one that matches applies to the key, so the last one in the list is your default. 81 82 Default is to reject. Putting a "*" at the end will keep everything not removed though. 83 84 ["-foo", "*"] // keep everything except foo 85 +/ 86 context.filterKeys = function var(var f, string[] filters) { 87 import std.path; 88 var o = var.emptyObject; 89 foreach(k, v; f) { 90 bool keep = false; 91 foreach(filter; filters) { 92 if(filter.length == 0) 93 throw new Exception("invalid filter"); 94 bool filterOff = filter[0] == '-'; 95 if(filterOff) 96 filter = filter[1 .. $]; 97 if(globMatch(k.get!string, filter)) { 98 keep = !filterOff; 99 break; 100 } 101 } 102 if(keep) 103 o[k] = v; 104 } 105 return o; 106 }; 107 108 context.encodeURIComponent = function string(var f) { 109 import std.uri; 110 return encodeComponent(f.get!string); 111 }; 112 113 context.formatDate = function string(string s) { 114 if(s.length < 10) 115 return s; 116 auto year = s[0 .. 4]; 117 auto month = s[5 .. 7]; 118 auto day = s[8 .. 10]; 119 120 return month ~ "/" ~ day ~ "/" ~ year; 121 }; 122 123 context.dayOfWeek = function string(string s) { 124 import std.datetime; 125 return daysOfWeekFullNames[Date.fromISOExtString(s[0 .. 10]).dayOfWeek]; 126 }; 127 128 context.formatTime = function string(string s) { 129 if(s.length < 20) 130 return s; 131 auto hour = s[11 .. 13].to!int; 132 auto minutes = s[14 .. 16].to!int; 133 auto seconds = s[17 .. 19].to!int; 134 135 auto am = (hour >= 12) ? "PM" : "AM"; 136 if(hour > 12) 137 hour -= 12; 138 139 return hour.to!string ~ (minutes < 10 ? ":0" : ":") ~ minutes.to!string ~ " " ~ am; 140 }; 141 142 // don't want checking meta or data to be an error 143 if(context.meta == null) 144 context.meta = var.emptyObject; 145 if(context.data == null) 146 context.data = var.emptyObject; 147 } 148 149 /++ 150 A loader object for reading raw template, so you can use something other than files if you like. 151 152 See [TemplateLoader.forDirectory] to a pre-packaged class that implements a loader for a particular directory. 153 154 History: 155 Added December 11, 2023 (dub v11.3) 156 +/ 157 interface TemplateLoader { 158 /++ 159 This is the main method to look up a template name and return its HTML as a string. 160 161 Typical implementation is to just `return std.file.readText(directory ~ name);` 162 +/ 163 string loadTemplateHtml(string name); 164 165 /++ 166 Returns a loader for files in the given directory. 167 +/ 168 static TemplateLoader forDirectory(string directoryName) { 169 if(directoryName.length && directoryName[$-1] != '/') 170 directoryName ~= "/"; 171 172 return new class TemplateLoader { 173 string loadTemplateHtml(string name) { 174 import std.file; 175 return readText(directoryName ~ name); 176 } 177 }; 178 } 179 } 180 181 /++ 182 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. 183 184 Parameters: 185 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) 186 context = the global object available to scripts inside the template 187 skeletonContext = the global object available to the skeleton template 188 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 189 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. 190 191 History: 192 Parameter `loader` was added on December 11, 2023 (dub v11.3) 193 +/ 194 Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null, TemplateLoader loader = null) { 195 import arsd.cgi; 196 197 if(loader is null) 198 loader = TemplateLoader.forDirectory("templates/"); 199 200 try { 201 addDefaultFunctions(context); 202 addDefaultFunctions(skeletonContext); 203 204 if(skeletonName.length == 0) 205 skeletonName = "skeleton.html"; 206 207 auto skeleton = new Document(loader.loadTemplateHtml(skeletonName), true, true); 208 auto document = new Document(); 209 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 210 document.parse("<root>" ~ loader.loadTemplateHtml(templateName) ~ "</root>", true, true); 211 212 expandTemplate(skeleton.root, skeletonContext, loader); 213 214 foreach(nav; skeleton.querySelectorAll("nav[data-relative-to]")) { 215 auto r = nav.getAttribute("data-relative-to"); 216 foreach(a; nav.querySelectorAll("a")) { 217 a.attrs.href = Uri(a.attrs.href).basedOn(Uri(r));// ~ a.attrs.href; 218 } 219 } 220 221 expandTemplate(document.root, context, loader); 222 223 // also do other unique elements and move them over. 224 // and have some kind of <document-fragment> that can be just reduced when going out in the final result. 225 226 // and try partials. 227 228 auto templateMain = document.requireSelector(":root > main"); 229 if(templateMain.hasAttribute("body-class")) { 230 skeleton.requireSelector("body").addClass(templateMain.getAttribute("body-class")); 231 templateMain.removeAttribute("body-class"); 232 } 233 234 skeleton.requireSelector("main").replaceWith(templateMain.removeFromTree); 235 236 if(auto title = document.querySelector(":root > title")) 237 skeleton.requireSelector(":root > head > title").innerHTML = title.innerHTML; 238 239 // also allow top-level unique id replacements 240 foreach(item; document.querySelectorAll(":root > [id]")) 241 skeleton.requireElementById(item.id).replaceWith(item.removeFromTree); 242 243 foreach(df; skeleton.querySelectorAll("document-fragment")) 244 df.stripOut(); 245 246 debug 247 skeleton.root.prependChild(new HtmlComment(null, templateName ~ " inside skeleton.html")); 248 249 return skeleton; 250 } catch(Exception e) { 251 throw new TemplateException(templateName, context, e); 252 //throw e; 253 } 254 } 255 256 /++ 257 Shows how top-level things from the template are moved to their corresponding items on the skeleton. 258 +/ 259 unittest { 260 // for the unittest, we want to inject a loader that uses plain strings instead of files. 261 auto testLoader = new class TemplateLoader { 262 string loadTemplateHtml(string name) { 263 switch(name) { 264 case "skeleton": 265 return ` 266 <html> 267 <head> 268 <!-- you can define replaceable things with ids --> 269 <!-- including <document-fragment>s which are stripped out when the template is finalized --> 270 <document-fragment id="header-stuff" /> 271 </head> 272 <body> 273 <main></main> 274 </body> 275 </html> 276 `; 277 case "main": 278 return ` 279 <main>Hello</main> 280 <document-fragment id="header-stuff"> 281 <title>My title</title> 282 </document-fragment> 283 `; 284 default: assert(0); 285 } 286 } 287 }; 288 289 Document doc = renderTemplate("main", var.emptyObject, var.emptyObject, "skeleton", testLoader); 290 291 assert(doc.querySelector("document-fragment") is null); // the <document-fragment> items are stripped out 292 assert(doc.querySelector("title") !is null); // but the stuff from inside it is brought in 293 assert(doc.requireSelector("main").textContent == "Hello"); // and the main from the template is moved to the skeelton 294 } 295 296 private void expandTemplate(Element root, var context, TemplateLoader loader) { 297 import std.string; 298 299 string replaceThingInString(string v) { 300 auto idx = v.indexOf("<%="); 301 if(idx == -1) 302 return v; 303 auto n = v[0 .. idx]; 304 auto r = v[idx + "<%=".length .. $]; 305 306 auto end = r.indexOf("%>"); 307 if(end == -1) 308 throw new Exception("unclosed asp code in attribute"); 309 auto code = r[0 .. end]; 310 r = r[end + "%>".length .. $]; 311 312 import arsd.script; 313 auto res = interpret(code, context).get!string; 314 315 return n ~ res ~ replaceThingInString(r); 316 } 317 318 foreach(k, v; root.attributes) { 319 if(k == "onrender") { 320 continue; 321 } 322 323 v = replaceThingInString(v); 324 325 root.setAttribute(k, v); 326 } 327 328 bool lastBoolResult; 329 330 foreach(ele; root.children) { 331 if(ele.tagName == "if-true") { 332 auto fragment = new DocumentFragment(null); 333 import arsd.script; 334 auto got = interpret(ele.attrs.cond, context).opCast!bool; 335 if(got) { 336 ele.tagName = "root"; 337 expandTemplate(ele, context, loader); 338 fragment.stealChildren(ele); 339 } 340 lastBoolResult = got; 341 ele.replaceWith(fragment); 342 } else if(ele.tagName == "or-else") { 343 auto fragment = new DocumentFragment(null); 344 if(!lastBoolResult) { 345 ele.tagName = "root"; 346 expandTemplate(ele, context, loader); 347 fragment.stealChildren(ele); 348 } 349 ele.replaceWith(fragment); 350 } else if(ele.tagName == "for-each") { 351 auto fragment = new DocumentFragment(null); 352 var nc = var.emptyObject(context); 353 lastBoolResult = false; 354 auto got = interpret(ele.attrs.over, context); 355 foreach(k, item; got) { 356 lastBoolResult = true; 357 nc[ele.attrs.as] = item; 358 if(ele.attrs.index.length) 359 nc[ele.attrs.index] = k; 360 auto clone = ele.cloneNode(true); 361 clone.tagName = "root"; // it certainly isn't a for-each anymore! 362 expandTemplate(clone, nc, loader); 363 364 fragment.stealChildren(clone); 365 } 366 ele.replaceWith(fragment); 367 } else if(ele.tagName == "render-template") { 368 import std.file; 369 auto templateName = ele.getAttribute("file"); 370 auto document = new Document(); 371 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 372 document.parse("<root>" ~ loader.loadTemplateHtml(templateName) ~ "</root>", true, true); 373 374 var obj = var.emptyObject; 375 obj.prototype = context; 376 377 // FIXME: there might be other data you pass from the parent... 378 if(auto data = ele.getAttribute("data")) { 379 obj["data"] = var.fromJson(data); 380 } 381 382 expandTemplate(document.root, obj, loader); 383 384 auto fragment = new DocumentFragment(null); 385 386 debug fragment.appendChild(new HtmlComment(null, templateName)); 387 fragment.stealChildren(document.root); 388 debug fragment.appendChild(new HtmlComment(null, "end " ~ templateName)); 389 390 ele.replaceWith(fragment); 391 } else if(ele.tagName == "hidden-form-data") { 392 auto from = interpret(ele.attrs.from, context); 393 auto name = ele.attrs.name; 394 395 auto form = new Form(null); 396 397 populateForm(form, from, name); 398 399 auto fragment = new DocumentFragment(null); 400 fragment.stealChildren(form); 401 402 ele.replaceWith(fragment); 403 } else if(auto asp = cast(AspCode) ele) { 404 auto code = asp.source[1 .. $-1]; 405 auto fragment = new DocumentFragment(null); 406 if(code[0] == '=') { 407 import arsd.script; 408 if(code.length > 5 && code[1 .. 5] == "HTML") { 409 auto got = interpret(code[5 .. $], context); 410 if(auto native = got.getWno!Element) 411 fragment.appendChild(native); 412 else 413 fragment.innerHTML = got.get!string; 414 } else { 415 auto got = interpret(code[1 .. $], context).get!string; 416 fragment.innerText = got; 417 } 418 } 419 asp.replaceWith(fragment); 420 } else if(ele.tagName == "script") { 421 auto source = ele.innerHTML; 422 string newCode; 423 check_more: 424 auto idx = source.indexOf("<%="); 425 if(idx != -1) { 426 newCode ~= source[0 .. idx]; 427 auto remaining = source[idx + 3 .. $]; 428 idx = remaining.indexOf("%>"); 429 if(idx == -1) 430 throw new Exception("unclosed asp code in script"); 431 auto code = remaining[0 .. idx]; 432 433 auto data = interpret(code, context); 434 newCode ~= data.toJson(); 435 436 source = remaining[idx + 2 .. $]; 437 goto check_more; 438 } 439 440 if(newCode is null) 441 {} // nothing needed 442 else { 443 newCode ~= source; 444 ele.innerRawSource = newCode; 445 } 446 } else { 447 expandTemplate(ele, context, loader); 448 } 449 } 450 451 if(root.hasAttribute("onrender")) { 452 var nc = var.emptyObject(context); 453 nc["this"] = wrapNativeObject(root); 454 nc["this"]["populateFrom"] = delegate var(var this_, var[] args) { 455 auto form = cast(Form) root; 456 if(form is null) return this_; 457 foreach(k, v; args[0]) { 458 populateForm(form, v, k.get!string); 459 } 460 return this_; 461 }; 462 interpret(root.getAttribute("onrender"), nc); 463 464 root.removeAttribute("onrender"); 465 } 466 } 467 468 void populateForm(Form form, var obj, string name) { 469 import std.string; 470 471 if(obj.payloadType == var.Type.Object) { 472 form.setValue(name, ""); 473 foreach(k, v; obj) { 474 auto fn = name.replace("%", k.get!string); 475 // should I unify structs and assoctiavite arrays? 476 populateForm(form, v, fn ~ "["~k.get!string~"]"); 477 //populateForm(form, v, fn ~"."~k.get!string); 478 } 479 } else { 480 //import std.stdio; writeln("SET ", name, " ", obj, " ", obj.payloadType); 481 form.setValue(name, obj.get!string); 482 } 483 484 } 485 486 /++ 487 Replaces `things[0]` with `things[1]` in `what` all at once. 488 Returns the new string. 489 490 History: 491 Added February 12, 2022. I might move it later. 492 +/ 493 string multiReplace(string what, string[] things...) { 494 import std.string; // FIXME: indexOf not actually ideal but meh 495 if(things.length == 0) 496 return what; 497 498 assert(things.length % 2 == 0); 499 500 string n; 501 502 while(what.length) { 503 int nextIndex = cast(int) what.length; 504 int nextThing = -1; 505 506 foreach(i, thing; things) { 507 if(i & 1) 508 continue; 509 510 auto idx = what.indexOf(thing); 511 if(idx != -1 && idx < nextIndex) { 512 nextIndex = cast(int) idx; 513 nextThing = cast(int) i; 514 } 515 } 516 517 if(nextThing == -1) { 518 n ~= what; 519 what = null; 520 } else { 521 n ~= what[0 .. nextIndex]; 522 what = what[nextIndex + things[nextThing].length .. $]; 523 n ~= things[nextThing + 1]; 524 continue; 525 } 526 } 527 528 return n; 529 } 530 531 immutable daysOfWeekFullNames = [ 532 "Sunday", 533 "Monday", 534 "Tuesday", 535 "Wednesday", 536 "Thursday", 537 "Friday", 538 "Saturday" 539 ]; 540 541 /++ 542 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. 543 544 Inside the template, the value returned by the function will be available in the context as the variable `data`. 545 +/ 546 struct Template { 547 string name; 548 } 549 /++ 550 UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides the default template skeleton file name. 551 +/ 552 struct Skeleton { 553 string name; 554 } 555 556 /++ 557 UDA to attach runtime metadata to a function. Will be available in the template. 558 559 History: 560 Added July 12, 2021 561 +/ 562 struct meta { 563 string name; 564 string value; 565 } 566 567 /++ 568 Can be used as a return value from one of your own methods when rendering websites with [WebPresenterWithTemplateSupport]. 569 +/ 570 struct RenderTemplate { 571 string name; 572 var context = var.emptyObject; 573 var skeletonContext = var.emptyObject; 574 string skeletonName; 575 } 576 577 578 /++ 579 Make a class that inherits from this with your further customizations, or minimally: 580 --- 581 class MyPresenter : WebPresenterWithTemplateSupport!MyPresenter { } 582 --- 583 +/ 584 template WebPresenterWithTemplateSupport(CTRP) { 585 import arsd.cgi; 586 class WebPresenterWithTemplateSupport : WebPresenter!(CTRP) { 587 override Element htmlContainer() { 588 try { 589 auto skeleton = renderTemplate("generic.html", var.emptyObject, var.emptyObject, "skeleton.html", templateLoader()); 590 return skeleton.requireSelector("main"); 591 } catch(Exception e) { 592 auto document = new Document("<html><body><p>generic.html trouble: <span id=\"ghe\"></span></p> <main></main></body></html>"); 593 document.requireSelector("#ghe").textContent = e.msg; 594 return document.requireSelector("main"); 595 } 596 } 597 598 static struct Meta { 599 typeof(null) at; 600 string templateName; 601 string skeletonName; 602 string[string] meta; 603 Form function(WebPresenterWithTemplateSupport presenter) automaticForm; 604 alias at this; 605 } 606 template methodMeta(alias method) { 607 static Meta helper() { 608 Meta ret; 609 610 // ret.at = typeof(super).methodMeta!method; 611 612 foreach(attr; __traits(getAttributes, method)) 613 static if(is(typeof(attr) == Template)) 614 ret.templateName = attr.name; 615 else static if(is(typeof(attr) == Skeleton)) 616 ret.skeletonName = attr.name; 617 else static if(is(typeof(attr) == .meta)) 618 ret.meta[attr.name] = attr.value; 619 620 ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) { 621 return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null); 622 }; 623 624 return ret; 625 } 626 enum methodMeta = helper(); 627 } 628 629 /// You can override this 630 void addContext(Cgi cgi, var ctx) {} 631 632 /++ 633 You can override this. The default is "templates/". Your returned string must end with '/'. 634 (in future versions it will probably allow a null return too, but right now it must be a /). 635 636 History: 637 Added December 6, 2023 (dub v11.3) 638 +/ 639 TemplateLoader templateLoader() { 640 return null; 641 } 642 643 void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) { 644 addContext(cgi, ret.context); 645 auto skeleton = renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName, templateLoader()); 646 cgi.setResponseContentType("text/html; charset=utf8"); 647 cgi.gzipResponse = true; 648 cgi.write(skeleton.toString(), true); 649 } 650 651 void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) { 652 if(meta.templateName.length) { 653 var sobj = var.emptyObject; 654 655 var obj = var.emptyObject; 656 657 obj.data = ret; 658 659 /+ 660 sobj.meta = var.emptyObject; 661 foreach(k,v; meta.meta) 662 sobj.meta[k] = v; 663 +/ 664 665 obj.meta = var.emptyObject; 666 foreach(k,v; meta.meta) 667 obj.meta[k] = v; 668 669 obj.meta.currentPath = cgi.pathInfo; 670 obj.meta.automaticForm = { return meta.automaticForm(this).toString; }; 671 672 presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta); 673 } else 674 super.presentSuccessfulReturnAsHtml(cgi, ret, meta); 675 } 676 } 677 } 678 679 /++ 680 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. 681 682 Parameters: 683 urlPrefix = the url prefix to trigger this handler, relative to the current dispatcher base 684 directory = the directory, under the template directory, to find the template files 685 skeleton = the name of the skeleton file inside the template directory 686 extension = the file extension to add to the url name to get the template name 687 688 To get the filename of the template from the url, it will: 689 690 1) Strip the url prefixes off to get just the filename 691 692 2) Concatenate the directory with the template directory 693 694 3) Add the extension to the givenname 695 696 $(PITFALL 697 The `templateDirectory` parameter may be removed or changed in the near future. 698 ) 699 700 History: 701 Added July 28, 2021 (documented dub v11.0) 702 +/ 703 auto serveTemplateDirectory()(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html", string templateDirectory = "templates/") { 704 import arsd.cgi; 705 import std.file; 706 707 assert(urlPrefix[0] == '/'); 708 assert(urlPrefix[$-1] == '/'); 709 710 assert(templateDirectory[$-1] == '/'); 711 712 static struct DispatcherDetails { 713 string directory; 714 string skeleton; 715 string extension; 716 string templateDirectory; 717 } 718 719 if(directory is null) 720 directory = urlPrefix[1 .. $]; 721 722 if(directory.length == 0) 723 directory = "./"; 724 725 assert(directory[$-1] == '/'); 726 727 static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) { 728 auto file = cgi.pathInfo[urlPrefix.length .. $]; 729 if(file.indexOf("/") != -1 || file.indexOf("\\") != -1) 730 return false; 731 732 auto fn = details.templateDirectory ~ details.directory ~ file ~ details.extension; 733 if(std.file.exists(fn)) { 734 cgi.setCache(true); 735 auto doc = renderTemplate(fn[details.templateDirectory.length.. $], var.emptyObject, var.emptyObject, details.skeleton, TemplateLoader.forDirectory(details.templateDirectory)); 736 cgi.gzipResponse = true; 737 cgi.write(doc.toString, true); 738 return true; 739 } else { 740 return false; 741 } 742 } 743 744 return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension, templateDirectory)); 745 }