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 Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null) { 148 import std.file; 149 import arsd.cgi; 150 151 try { 152 addDefaultFunctions(context); 153 addDefaultFunctions(skeletonContext); 154 155 if(skeletonName.length == 0) 156 skeletonName = "skeleton.html"; 157 158 auto skeleton = new Document(readText("templates/" ~ skeletonName), true, true); 159 auto document = new Document(); 160 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 161 document.parse("<root>" ~ readText("templates/" ~ templateName) ~ "</root>", true, true); 162 163 expandTemplate(skeleton.root, skeletonContext); 164 165 foreach(nav; skeleton.querySelectorAll("nav[data-relative-to]")) { 166 auto r = nav.getAttribute("data-relative-to"); 167 foreach(a; nav.querySelectorAll("a")) { 168 a.attrs.href = Uri(a.attrs.href).basedOn(Uri(r));// ~ a.attrs.href; 169 } 170 } 171 172 expandTemplate(document.root, context); 173 174 // also do other unique elements and move them over. 175 // and try partials. 176 177 auto templateMain = document.requireSelector(":root > main"); 178 if(templateMain.hasAttribute("body-class")) { 179 skeleton.requireSelector("body").addClass(templateMain.getAttribute("body-class")); 180 templateMain.removeAttribute("body-class"); 181 } 182 183 skeleton.requireSelector("main").replaceWith(templateMain.removeFromTree); 184 if(auto title = document.querySelector(":root > title")) 185 skeleton.requireSelector(":root > head > title").innerHTML = title.innerHTML; 186 187 debug 188 skeleton.root.prependChild(new HtmlComment(null, templateName ~ " inside skeleton.html")); 189 190 return skeleton; 191 } catch(Exception e) { 192 throw new TemplateException(templateName, context, e); 193 //throw e; 194 } 195 } 196 197 // I don't particularly like this 198 void expandTemplate(Element root, var context) { 199 import std.string; 200 201 string replaceThingInString(string v) { 202 auto idx = v.indexOf("<%="); 203 if(idx == -1) 204 return v; 205 auto n = v[0 .. idx]; 206 auto r = v[idx + "<%=".length .. $]; 207 208 auto end = r.indexOf("%>"); 209 if(end == -1) 210 throw new Exception("unclosed asp code in attribute"); 211 auto code = r[0 .. end]; 212 r = r[end + "%>".length .. $]; 213 214 import arsd.script; 215 auto res = interpret(code, context).get!string; 216 217 return n ~ res ~ replaceThingInString(r); 218 } 219 220 foreach(k, v; root.attributes) { 221 if(k == "onrender") { 222 continue; 223 } 224 225 v = replaceThingInString(v); 226 227 root.setAttribute(k, v); 228 } 229 230 bool lastBoolResult; 231 232 foreach(ele; root.children) { 233 if(ele.tagName == "if-true") { 234 auto fragment = new DocumentFragment(null); 235 import arsd.script; 236 auto got = interpret(ele.attrs.cond, context).opCast!bool; 237 if(got) { 238 ele.tagName = "root"; 239 expandTemplate(ele, context); 240 fragment.stealChildren(ele); 241 } 242 lastBoolResult = got; 243 ele.replaceWith(fragment); 244 } else if(ele.tagName == "or-else") { 245 auto fragment = new DocumentFragment(null); 246 if(!lastBoolResult) { 247 ele.tagName = "root"; 248 expandTemplate(ele, context); 249 fragment.stealChildren(ele); 250 } 251 ele.replaceWith(fragment); 252 } else if(ele.tagName == "for-each") { 253 auto fragment = new DocumentFragment(null); 254 var nc = var.emptyObject(context); 255 lastBoolResult = false; 256 auto got = interpret(ele.attrs.over, context); 257 foreach(k, item; got) { 258 lastBoolResult = true; 259 nc[ele.attrs.as] = item; 260 if(ele.attrs.index.length) 261 nc[ele.attrs.index] = k; 262 auto clone = ele.cloneNode(true); 263 clone.tagName = "root"; // it certainly isn't a for-each anymore! 264 expandTemplate(clone, nc); 265 266 fragment.stealChildren(clone); 267 } 268 ele.replaceWith(fragment); 269 } else if(ele.tagName == "render-template") { 270 import std.file; 271 auto templateName = ele.getAttribute("file"); 272 auto document = new Document(); 273 document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom 274 document.parse("<root>" ~ readText("templates/" ~ templateName) ~ "</root>", true, true); 275 276 var obj = var.emptyObject; 277 obj.prototype = context; 278 279 // FIXME: there might be other data you pass from the parent... 280 if(auto data = ele.getAttribute("data")) { 281 obj["data"] = var.fromJson(data); 282 } 283 284 expandTemplate(document.root, obj); 285 286 auto fragment = new DocumentFragment(null); 287 288 debug fragment.appendChild(new HtmlComment(null, templateName)); 289 fragment.stealChildren(document.root); 290 debug fragment.appendChild(new HtmlComment(null, "end " ~ templateName)); 291 292 ele.replaceWith(fragment); 293 } else if(ele.tagName == "hidden-form-data") { 294 auto from = interpret(ele.attrs.from, context); 295 auto name = ele.attrs.name; 296 297 auto form = new Form(null); 298 299 populateForm(form, from, name); 300 301 auto fragment = new DocumentFragment(null); 302 fragment.stealChildren(form); 303 304 ele.replaceWith(fragment); 305 } else if(auto asp = cast(AspCode) ele) { 306 auto code = asp.source[1 .. $-1]; 307 auto fragment = new DocumentFragment(null); 308 if(code[0] == '=') { 309 import arsd.script; 310 if(code.length > 5 && code[1 .. 5] == "HTML") { 311 auto got = interpret(code[5 .. $], context); 312 if(auto native = got.getWno!Element) 313 fragment.appendChild(native); 314 else 315 fragment.innerHTML = got.get!string; 316 } else { 317 auto got = interpret(code[1 .. $], context).get!string; 318 fragment.innerText = got; 319 } 320 } 321 asp.replaceWith(fragment); 322 } else if(ele.tagName == "script") { 323 auto source = ele.innerHTML; 324 string newCode; 325 check_more: 326 auto idx = source.indexOf("<%="); 327 if(idx != -1) { 328 newCode ~= source[0 .. idx]; 329 auto remaining = source[idx + 3 .. $]; 330 idx = remaining.indexOf("%>"); 331 if(idx == -1) 332 throw new Exception("unclosed asp code in script"); 333 auto code = remaining[0 .. idx]; 334 335 auto data = interpret(code, context); 336 newCode ~= data.toJson(); 337 338 source = remaining[idx + 2 .. $]; 339 goto check_more; 340 } 341 342 if(newCode is null) 343 {} // nothing needed 344 else { 345 newCode ~= source; 346 ele.innerRawSource = newCode; 347 } 348 } else { 349 expandTemplate(ele, context); 350 } 351 } 352 353 if(root.hasAttribute("onrender")) { 354 var nc = var.emptyObject(context); 355 nc["this"] = wrapNativeObject(root); 356 nc["this"]["populateFrom"] = delegate var(var this_, var[] args) { 357 auto form = cast(Form) root; 358 if(form is null) return this_; 359 foreach(k, v; args[0]) { 360 populateForm(form, v, k.get!string); 361 } 362 return this_; 363 }; 364 interpret(root.getAttribute("onrender"), nc); 365 366 root.removeAttribute("onrender"); 367 } 368 } 369 370 void populateForm(Form form, var obj, string name) { 371 import std.string; 372 373 if(obj.payloadType == var.Type.Object) { 374 form.setValue(name, ""); 375 foreach(k, v; obj) { 376 auto fn = name.replace("%", k.get!string); 377 // should I unify structs and assoctiavite arrays? 378 populateForm(form, v, fn ~ "["~k.get!string~"]"); 379 //populateForm(form, v, fn ~"."~k.get!string); 380 } 381 } else { 382 //import std.stdio; writeln("SET ", name, " ", obj, " ", obj.payloadType); 383 form.setValue(name, obj.get!string); 384 } 385 386 } 387 388 /++ 389 Replaces `things[0]` with `things[1]` in `what` all at once. 390 Returns the new string. 391 392 History: 393 Added February 12, 2022. I might move it later. 394 +/ 395 string multiReplace(string what, string[] things...) { 396 import std.string; // FIXME: indexOf not actually ideal but meh 397 if(things.length == 0) 398 return what; 399 400 assert(things.length % 2 == 0); 401 402 string n; 403 404 while(what.length) { 405 int nextIndex = cast(int) what.length; 406 int nextThing = -1; 407 408 foreach(i, thing; things) { 409 if(i & 1) 410 continue; 411 412 auto idx = what.indexOf(thing); 413 if(idx != -1 && idx < nextIndex) { 414 nextIndex = cast(int) idx; 415 nextThing = cast(int) i; 416 } 417 } 418 419 if(nextThing == -1) { 420 n ~= what; 421 what = null; 422 } else { 423 n ~= what[0 .. nextIndex]; 424 what = what[nextIndex + things[nextThing].length .. $]; 425 n ~= things[nextThing + 1]; 426 continue; 427 } 428 } 429 430 return n; 431 } 432 433 immutable daysOfWeekFullNames = [ 434 "Sunday", 435 "Monday", 436 "Tuesday", 437 "Wednesday", 438 "Thursday", 439 "Friday", 440 "Saturday" 441 ]; 442 443 /++ 444 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. 445 446 Inside the template, the value returned by the function will be available in the context as the variable `data`. 447 +/ 448 struct Template { 449 string name; 450 } 451 /++ 452 UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides the default template skeleton file name. 453 +/ 454 struct Skeleton { 455 string name; 456 } 457 458 /++ 459 UDA to attach runtime metadata to a function. Will be available in the template. 460 461 History: 462 Added July 12, 2021 463 +/ 464 struct meta { 465 string name; 466 string value; 467 } 468 469 /++ 470 Can be used as a return value from one of your own methods when rendering websites with [WebPresenterWithTemplateSupport]. 471 +/ 472 struct RenderTemplate { 473 string name; 474 var context = var.emptyObject; 475 var skeletonContext = var.emptyObject; 476 string skeletonName; 477 } 478 479 480 /++ 481 Make a class that inherits from this with your further customizations, or minimally: 482 --- 483 class MyPresenter : WebPresenterWithTemplateSupport!MyPresenter { } 484 --- 485 +/ 486 template WebPresenterWithTemplateSupport(CTRP) { 487 import arsd.cgi; 488 class WebPresenterWithTemplateSupport : WebPresenter!(CTRP) { 489 override Element htmlContainer() { 490 auto skeleton = renderTemplate("generic.html"); 491 return skeleton.requireSelector("main"); 492 } 493 494 static struct Meta { 495 typeof(null) at; 496 string templateName; 497 string skeletonName; 498 string[string] meta; 499 Form function(WebPresenterWithTemplateSupport presenter) automaticForm; 500 alias at this; 501 } 502 template methodMeta(alias method) { 503 static Meta helper() { 504 Meta ret; 505 506 // ret.at = typeof(super).methodMeta!method; 507 508 foreach(attr; __traits(getAttributes, method)) 509 static if(is(typeof(attr) == Template)) 510 ret.templateName = attr.name; 511 else static if(is(typeof(attr) == Skeleton)) 512 ret.skeletonName = attr.name; 513 else static if(is(typeof(attr) == .meta)) 514 ret.meta[attr.name] = attr.value; 515 516 ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) { 517 return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null); 518 }; 519 520 return ret; 521 } 522 enum methodMeta = helper(); 523 } 524 525 /// You can override this 526 void addContext(Cgi cgi, var ctx) {} 527 528 void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) { 529 addContext(cgi, ret.context); 530 auto skeleton = renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName); 531 cgi.setResponseContentType("text/html; charset=utf8"); 532 cgi.gzipResponse = true; 533 cgi.write(skeleton.toString(), true); 534 } 535 536 void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) { 537 if(meta.templateName.length) { 538 var sobj = var.emptyObject; 539 540 var obj = var.emptyObject; 541 542 obj.data = ret; 543 544 /+ 545 sobj.meta = var.emptyObject; 546 foreach(k,v; meta.meta) 547 sobj.meta[k] = v; 548 +/ 549 550 obj.meta = var.emptyObject; 551 foreach(k,v; meta.meta) 552 obj.meta[k] = v; 553 554 obj.meta.currentPath = cgi.pathInfo; 555 obj.meta.automaticForm = { return meta.automaticForm(this).toString; }; 556 557 presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta); 558 } else 559 super.presentSuccessfulReturnAsHtml(cgi, ret, meta); 560 } 561 } 562 } 563 564 /++ 565 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. 566 567 History: 568 Added July 28, 2021 (documented dub v11.0) 569 +/ 570 auto serveTemplateDirectory()(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html") { 571 import arsd.cgi; 572 import std.file; 573 574 assert(urlPrefix[0] == '/'); 575 assert(urlPrefix[$-1] == '/'); 576 577 static struct DispatcherDetails { 578 string directory; 579 string skeleton; 580 string extension; 581 } 582 583 if(directory is null) 584 directory = urlPrefix[1 .. $]; 585 586 assert(directory[$-1] == '/'); 587 588 static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) { 589 auto file = cgi.pathInfo[urlPrefix.length .. $]; 590 if(file.indexOf("/") != -1 || file.indexOf("\\") != -1) 591 return false; 592 593 auto fn = "templates/" ~ details.directory ~ file ~ details.extension; 594 if(std.file.exists(fn)) { 595 cgi.setCache(true); 596 auto doc = renderTemplate(fn["templates/".length.. $]); 597 cgi.gzipResponse = true; 598 cgi.write(doc.toString, true); 599 return true; 600 } else { 601 return false; 602 } 603 } 604 605 return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension)); 606 }