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 auto skeleton = renderTemplate("generic.html", var.emptyObject, var.emptyObject, "skeleton.html", templateLoader()); 589 return skeleton.requireSelector("main"); 590 } 591 592 static struct Meta { 593 typeof(null) at; 594 string templateName; 595 string skeletonName; 596 string[string] meta; 597 Form function(WebPresenterWithTemplateSupport presenter) automaticForm; 598 alias at this; 599 } 600 template methodMeta(alias method) { 601 static Meta helper() { 602 Meta ret; 603 604 // ret.at = typeof(super).methodMeta!method; 605 606 foreach(attr; __traits(getAttributes, method)) 607 static if(is(typeof(attr) == Template)) 608 ret.templateName = attr.name; 609 else static if(is(typeof(attr) == Skeleton)) 610 ret.skeletonName = attr.name; 611 else static if(is(typeof(attr) == .meta)) 612 ret.meta[attr.name] = attr.value; 613 614 ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) { 615 return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null); 616 }; 617 618 return ret; 619 } 620 enum methodMeta = helper(); 621 } 622 623 /// You can override this 624 void addContext(Cgi cgi, var ctx) {} 625 626 /++ 627 You can override this. The default is "templates/". Your returned string must end with '/'. 628 (in future versions it will probably allow a null return too, but right now it must be a /). 629 630 History: 631 Added December 6, 2023 (dub v11.3) 632 +/ 633 TemplateLoader templateLoader() { 634 return null; 635 } 636 637 void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) { 638 addContext(cgi, ret.context); 639 auto skeleton = renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName, templateLoader()); 640 cgi.setResponseContentType("text/html; charset=utf8"); 641 cgi.gzipResponse = true; 642 cgi.write(skeleton.toString(), true); 643 } 644 645 void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) { 646 if(meta.templateName.length) { 647 var sobj = var.emptyObject; 648 649 var obj = var.emptyObject; 650 651 obj.data = ret; 652 653 /+ 654 sobj.meta = var.emptyObject; 655 foreach(k,v; meta.meta) 656 sobj.meta[k] = v; 657 +/ 658 659 obj.meta = var.emptyObject; 660 foreach(k,v; meta.meta) 661 obj.meta[k] = v; 662 663 obj.meta.currentPath = cgi.pathInfo; 664 obj.meta.automaticForm = { return meta.automaticForm(this).toString; }; 665 666 presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta); 667 } else 668 super.presentSuccessfulReturnAsHtml(cgi, ret, meta); 669 } 670 } 671 } 672 673 /++ 674 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. 675 676 History: 677 Added July 28, 2021 (documented dub v11.0) 678 +/ 679 auto serveTemplateDirectory()(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html") { 680 import arsd.cgi; 681 import std.file; 682 683 assert(urlPrefix[0] == '/'); 684 assert(urlPrefix[$-1] == '/'); 685 686 static struct DispatcherDetails { 687 string directory; 688 string skeleton; 689 string extension; 690 } 691 692 if(directory is null) 693 directory = urlPrefix[1 .. $]; 694 695 assert(directory[$-1] == '/'); 696 697 static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) { 698 auto file = cgi.pathInfo[urlPrefix.length .. $]; 699 if(file.indexOf("/") != -1 || file.indexOf("\\") != -1) 700 return false; 701 702 auto fn = "templates/" ~ details.directory ~ file ~ details.extension; 703 if(std.file.exists(fn)) { 704 cgi.setCache(true); 705 auto doc = renderTemplate(fn["templates/".length.. $]); 706 cgi.gzipResponse = true; 707 cgi.write(doc.toString, true); 708 return true; 709 } else { 710 return false; 711 } 712 } 713 714 return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension)); 715 }