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