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 <script> 31 var a = <%= some_var %>; // it will be json encoded in a script tag, so it can be safely used from Javascript 32 </script> 33 </main> 34 ``` 35 36 Functions available: 37 `encodeURIComponent`, `formatDate`, `dayOfWeek`, `formatTime`, `filterKeys` 38 39 History: 40 Things inside script tag were added on January 7, 2022. 41 42 This module was added to dub on September 11, 2023 (dub v11.2). 43 44 It was originally written in July 2019 to support a demonstration of moving a ruby on rails app to D. 45 +/ 46 module arsd.webtemplate; 47 48 // FIXME: make script exceptions show line from the template it was in too 49 50 import arsd.script; 51 import arsd.dom; 52 53 public import arsd.jsvar : var; 54 55 // FIXME: want to show additional info from the exception, neatly integrated, whenever possible. 56 class TemplateException : Exception { 57 string templateName; 58 var context; 59 Exception e; 60 this(string templateName, var context, Exception e) { 61 this.templateName = templateName; 62 this.context = context; 63 this.e = e; 64 65 super("Exception in template " ~ templateName ~ ": " ~ e.msg); 66 } 67 } 68 69 void addDefaultFunctions(var context) { 70 import std.conv; 71 // FIXME: I prolly want it to just set the prototype or something 72 73 /+ 74 foo |> filterKeys(["foo", "bar"]); 75 76 It needs to match the filter, then if it is -pattern, it is removed and if it is +pattern, it is retained. 77 78 First one that matches applies to the key, so the last one in the list is your default. 79 80 Default is to reject. Putting a "*" at the end will keep everything not removed though. 81 82 ["-foo", "*"] // keep everything except foo 83 +/ 84 context.filterKeys = function var(var f, string[] filters) { 85 import std.path; 86 var o = var.emptyObject; 87 foreach(k, v; f) { 88 bool keep = false; 89 foreach(filter; filters) { 90 if(filter.length == 0) 91 throw new Exception("invalid filter"); 92 bool filterOff = filter[0] == '-'; 93 if(filterOff) 94 filter = filter[1 .. $]; 95 if(globMatch(k.get!string, filter)) { 96 keep = !filterOff; 97 break; 98 } 99 } 100 if(keep) 101 o[k] = v; 102 } 103 return o; 104 }; 105 106 context.encodeURIComponent = function string(var f) { 107 import std.uri; 108 return encodeComponent(f.get!string); 109 }; 110 111 context.formatDate = function string(string s) { 112 if(s.length < 10) 113 return s; 114 auto year = s[0 .. 4]; 115 auto month = s[5 .. 7]; 116 auto day = s[8 .. 10]; 117 118 return month ~ "/" ~ day ~ "/" ~ year; 119 }; 120 121 context.dayOfWeek = function string(string s) { 122 import std.datetime; 123 return daysOfWeekFullNames[Date.fromISOExtString(s[0 .. 10]).dayOfWeek]; 124 }; 125 126 context.formatTime = function string(string s) { 127 if(s.length < 20) 128 return s; 129 auto hour = s[11 .. 13].to!int; 130 auto minutes = s[14 .. 16].to!int; 131 auto seconds = s[17 .. 19].to!int; 132 133 auto am = (hour >= 12) ? "PM" : "AM"; 134 if(hour > 12) 135 hour -= 12; 136 137 return hour.to!string ~ (minutes < 10 ? ":0" : ":") ~ minutes.to!string ~ " " ~ am; 138 }; 139 140 // don't want checking meta or data to be an error 141 if(context.meta == null) 142 context.meta = var.emptyObject; 143 if(context.data == null) 144 context.data = var.emptyObject; 145 } 146 147 /// 148 Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null) { 149 import std.file; 150 import arsd.cgi; 151 152 try { 153 addDefaultFunctions(context); 154 addDefaultFunctions(skeletonContext); 155 156 if(skeletonName.length == 0) 157 skeletonName = "skeleton.html"; 158 159 auto skeleton = new Document(readText("templates/" ~ skeletonName), true, true); 160 auto document = new Document(); 161 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 162 document.parse("<root>" ~ readText("templates/" ~ templateName) ~ "</root>", true, true); 163 164 expandTemplate(skeleton.root, skeletonContext); 165 166 foreach(nav; skeleton.querySelectorAll("nav[data-relative-to]")) { 167 auto r = nav.getAttribute("data-relative-to"); 168 foreach(a; nav.querySelectorAll("a")) { 169 a.attrs.href = Uri(a.attrs.href).basedOn(Uri(r));// ~ a.attrs.href; 170 } 171 } 172 173 expandTemplate(document.root, context); 174 175 // also do other unique elements and move them over. 176 // and try partials. 177 178 auto templateMain = document.requireSelector(":root > main"); 179 if(templateMain.hasAttribute("body-class")) { 180 skeleton.requireSelector("body").addClass(templateMain.getAttribute("body-class")); 181 templateMain.removeAttribute("body-class"); 182 } 183 184 skeleton.requireSelector("main").replaceWith(templateMain.removeFromTree); 185 if(auto title = document.querySelector(":root > title")) 186 skeleton.requireSelector(":root > head > title").innerHTML = title.innerHTML; 187 188 debug 189 skeleton.root.prependChild(new HtmlComment(null, templateName ~ " inside skeleton.html")); 190 191 return skeleton; 192 } catch(Exception e) { 193 throw new TemplateException(templateName, context, e); 194 //throw e; 195 } 196 } 197 198 // I don't particularly like this 199 void expandTemplate(Element root, var context) { 200 import std.string; 201 202 string replaceThingInString(string v) { 203 auto idx = v.indexOf("<%="); 204 if(idx == -1) 205 return v; 206 auto n = v[0 .. idx]; 207 auto r = v[idx + "<%=".length .. $]; 208 209 auto end = r.indexOf("%>"); 210 if(end == -1) 211 throw new Exception("unclosed asp code in attribute"); 212 auto code = r[0 .. end]; 213 r = r[end + "%>".length .. $]; 214 215 import arsd.script; 216 auto res = interpret(code, context).get!string; 217 218 return n ~ res ~ replaceThingInString(r); 219 } 220 221 foreach(k, v; root.attributes) { 222 if(k == "onrender") { 223 continue; 224 } 225 226 v = replaceThingInString(v); 227 228 root.setAttribute(k, v); 229 } 230 231 bool lastBoolResult; 232 233 foreach(ele; root.children) { 234 if(ele.tagName == "if-true") { 235 auto fragment = new DocumentFragment(null); 236 import arsd.script; 237 auto got = interpret(ele.attrs.cond, context).opCast!bool; 238 if(got) { 239 ele.tagName = "root"; 240 expandTemplate(ele, context); 241 fragment.stealChildren(ele); 242 } 243 lastBoolResult = got; 244 ele.replaceWith(fragment); 245 } else if(ele.tagName == "or-else") { 246 auto fragment = new DocumentFragment(null); 247 if(!lastBoolResult) { 248 ele.tagName = "root"; 249 expandTemplate(ele, context); 250 fragment.stealChildren(ele); 251 } 252 ele.replaceWith(fragment); 253 } else if(ele.tagName == "for-each") { 254 auto fragment = new DocumentFragment(null); 255 var nc = var.emptyObject(context); 256 lastBoolResult = false; 257 auto got = interpret(ele.attrs.over, context); 258 foreach(k, item; got) { 259 lastBoolResult = true; 260 nc[ele.attrs.as] = item; 261 if(ele.attrs.index.length) 262 nc[ele.attrs.index] = k; 263 auto clone = ele.cloneNode(true); 264 clone.tagName = "root"; // it certainly isn't a for-each anymore! 265 expandTemplate(clone, nc); 266 267 fragment.stealChildren(clone); 268 } 269 ele.replaceWith(fragment); 270 } else if(ele.tagName == "render-template") { 271 import std.file; 272 auto templateName = ele.getAttribute("file"); 273 auto document = new Document(); 274 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 275 document.parse("<root>" ~ readText("templates/" ~ templateName) ~ "</root>", true, true); 276 277 var obj = var.emptyObject; 278 obj.prototype = context; 279 280 // FIXME: there might be other data you pass from the parent... 281 if(auto data = ele.getAttribute("data")) { 282 obj["data"] = var.fromJson(data); 283 } 284 285 expandTemplate(document.root, obj); 286 287 auto fragment = new DocumentFragment(null); 288 289 debug fragment.appendChild(new HtmlComment(null, templateName)); 290 fragment.stealChildren(document.root); 291 debug fragment.appendChild(new HtmlComment(null, "end " ~ templateName)); 292 293 ele.replaceWith(fragment); 294 } else if(ele.tagName == "hidden-form-data") { 295 auto from = interpret(ele.attrs.from, context); 296 auto name = ele.attrs.name; 297 298 auto form = new Form(null); 299 300 populateForm(form, from, name); 301 302 auto fragment = new DocumentFragment(null); 303 fragment.stealChildren(form); 304 305 ele.replaceWith(fragment); 306 } else if(auto asp = cast(AspCode) ele) { 307 auto code = asp.source[1 .. $-1]; 308 auto fragment = new DocumentFragment(null); 309 if(code[0] == '=') { 310 import arsd.script; 311 if(code.length > 5 && code[1 .. 5] == "HTML") { 312 auto got = interpret(code[5 .. $], context); 313 if(auto native = got.getWno!Element) 314 fragment.appendChild(native); 315 else 316 fragment.innerHTML = got.get!string; 317 } else { 318 auto got = interpret(code[1 .. $], context).get!string; 319 fragment.innerText = got; 320 } 321 } 322 asp.replaceWith(fragment); 323 } else if(ele.tagName == "script") { 324 auto source = ele.innerHTML; 325 string newCode; 326 check_more: 327 auto idx = source.indexOf("<%="); 328 if(idx != -1) { 329 newCode ~= source[0 .. idx]; 330 auto remaining = source[idx + 3 .. $]; 331 idx = remaining.indexOf("%>"); 332 if(idx == -1) 333 throw new Exception("unclosed asp code in script"); 334 auto code = remaining[0 .. idx]; 335 336 auto data = interpret(code, context); 337 newCode ~= data.toJson(); 338 339 source = remaining[idx + 2 .. $]; 340 goto check_more; 341 } 342 343 if(newCode is null) 344 {} // nothing needed 345 else { 346 newCode ~= source; 347 ele.innerRawSource = newCode; 348 } 349 } else { 350 expandTemplate(ele, context); 351 } 352 } 353 354 if(root.hasAttribute("onrender")) { 355 var nc = var.emptyObject(context); 356 nc["this"] = wrapNativeObject(root); 357 nc["this"]["populateFrom"] = delegate var(var this_, var[] args) { 358 auto form = cast(Form) root; 359 if(form is null) return this_; 360 foreach(k, v; args[0]) { 361 populateForm(form, v, k.get!string); 362 } 363 return this_; 364 }; 365 interpret(root.getAttribute("onrender"), nc); 366 367 root.removeAttribute("onrender"); 368 } 369 } 370 371 void populateForm(Form form, var obj, string name) { 372 import std.string; 373 374 if(obj.payloadType == var.Type.Object) { 375 form.setValue(name, ""); 376 foreach(k, v; obj) { 377 auto fn = name.replace("%", k.get!string); 378 // should I unify structs and assoctiavite arrays? 379 populateForm(form, v, fn ~ "["~k.get!string~"]"); 380 //populateForm(form, v, fn ~"."~k.get!string); 381 } 382 } else { 383 //import std.stdio; writeln("SET ", name, " ", obj, " ", obj.payloadType); 384 form.setValue(name, obj.get!string); 385 } 386 387 } 388 389 /++ 390 Replaces `things[0]` with `things[1]` in `what` all at once. 391 Returns the new string. 392 393 History: 394 Added February 12, 2022. I might move it later. 395 +/ 396 string multiReplace(string what, string[] things...) { 397 import std.string; // FIXME: indexOf not actually ideal but meh 398 if(things.length == 0) 399 return what; 400 401 assert(things.length % 2 == 0); 402 403 string n; 404 405 while(what.length) { 406 int nextIndex = cast(int) what.length; 407 int nextThing = -1; 408 409 foreach(i, thing; things) { 410 if(i & 1) 411 continue; 412 413 auto idx = what.indexOf(thing); 414 if(idx != -1 && idx < nextIndex) { 415 nextIndex = cast(int) idx; 416 nextThing = cast(int) i; 417 } 418 } 419 420 if(nextThing == -1) { 421 n ~= what; 422 what = null; 423 } else { 424 n ~= what[0 .. nextIndex]; 425 what = what[nextIndex + things[nextThing].length .. $]; 426 n ~= things[nextThing + 1]; 427 continue; 428 } 429 } 430 431 return n; 432 } 433 434 immutable daysOfWeekFullNames = [ 435 "Sunday", 436 "Monday", 437 "Tuesday", 438 "Wednesday", 439 "Thursday", 440 "Friday", 441 "Saturday" 442 ]; 443 444 /++ 445 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. 446 447 Inside the template, the value returned by the function will be available in the context as the variable `data`. 448 +/ 449 struct Template { 450 string name; 451 } 452 /++ 453 UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides the default template skeleton file name. 454 +/ 455 struct Skeleton { 456 string name; 457 } 458 459 /++ 460 UDA to attach runtime metadata to a function. Will be available in the template. 461 462 History: 463 Added July 12, 2021 464 +/ 465 struct meta { 466 string name; 467 string value; 468 } 469 470 /++ 471 Can be used as a return value from one of your own methods when rendering websites with [WebPresenterWithTemplateSupport]. 472 +/ 473 struct RenderTemplate { 474 string name; 475 var context = var.emptyObject; 476 var skeletonContext = var.emptyObject; 477 string skeletonName; 478 } 479 480 481 /++ 482 Make a class that inherits from this with your further customizations, or minimally: 483 --- 484 class MyPresenter : WebPresenterWithTemplateSupport!MyPresenter { } 485 --- 486 +/ 487 template WebPresenterWithTemplateSupport(CTRP) { 488 import arsd.cgi; 489 class WebPresenterWithTemplateSupport : WebPresenter!(CTRP) { 490 override Element htmlContainer() { 491 auto skeleton = renderTemplate("generic.html"); 492 return skeleton.requireSelector("main"); 493 } 494 495 static struct Meta { 496 typeof(null) at; 497 string templateName; 498 string skeletonName; 499 string[string] meta; 500 Form function(WebPresenterWithTemplateSupport presenter) automaticForm; 501 alias at this; 502 } 503 template methodMeta(alias method) { 504 static Meta helper() { 505 Meta ret; 506 507 // ret.at = typeof(super).methodMeta!method; 508 509 foreach(attr; __traits(getAttributes, method)) 510 static if(is(typeof(attr) == Template)) 511 ret.templateName = attr.name; 512 else static if(is(typeof(attr) == Skeleton)) 513 ret.skeletonName = attr.name; 514 else static if(is(typeof(attr) == .meta)) 515 ret.meta[attr.name] = attr.value; 516 517 ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) { 518 return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null); 519 }; 520 521 return ret; 522 } 523 enum methodMeta = helper(); 524 } 525 526 /// You can override this 527 void addContext(Cgi cgi, var ctx) {} 528 529 void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) { 530 addContext(cgi, ret.context); 531 auto skeleton = renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName); 532 cgi.setResponseContentType("text/html; charset=utf8"); 533 cgi.gzipResponse = true; 534 cgi.write(skeleton.toString(), true); 535 } 536 537 void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) { 538 if(meta.templateName.length) { 539 var sobj = var.emptyObject; 540 541 var obj = var.emptyObject; 542 543 obj.data = ret; 544 545 /+ 546 sobj.meta = var.emptyObject; 547 foreach(k,v; meta.meta) 548 sobj.meta[k] = v; 549 +/ 550 551 obj.meta = var.emptyObject; 552 foreach(k,v; meta.meta) 553 obj.meta[k] = v; 554 555 obj.meta.currentPath = cgi.pathInfo; 556 obj.meta.automaticForm = { return meta.automaticForm(this).toString; }; 557 558 presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta); 559 } else 560 super.presentSuccessfulReturnAsHtml(cgi, ret, meta); 561 } 562 } 563 } 564 565 /++ 566 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. 567 568 History: 569 Added July 28, 2021 (documented dub v11.0) 570 +/ 571 auto serveTemplateDirectory()(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html") { 572 import arsd.cgi; 573 import std.file; 574 575 assert(urlPrefix[0] == '/'); 576 assert(urlPrefix[$-1] == '/'); 577 578 static struct DispatcherDetails { 579 string directory; 580 string skeleton; 581 string extension; 582 } 583 584 if(directory is null) 585 directory = urlPrefix[1 .. $]; 586 587 assert(directory[$-1] == '/'); 588 589 static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) { 590 auto file = cgi.pathInfo[urlPrefix.length .. $]; 591 if(file.indexOf("/") != -1 || file.indexOf("\\") != -1) 592 return false; 593 594 auto fn = "templates/" ~ details.directory ~ file ~ details.extension; 595 if(std.file.exists(fn)) { 596 cgi.setCache(true); 597 auto doc = renderTemplate(fn["templates/".length.. $]); 598 cgi.gzipResponse = true; 599 cgi.write(doc.toString, true); 600 return true; 601 } else { 602 return false; 603 } 604 } 605 606 return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension)); 607 }