The OpenD Programming Language

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 body-class="foo">
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 /++
58 	A class to render web template files into HTML documents.
59 
60 
61 	You can customize various parts of this with subclassing and dependency injection. Customization hook points include:
62 
63 	$(NUMBERED_LIST
64 		* You pass a [TemplateLoader] instance to the constructor. This object is responsible for loading a particular
65 		named template and returning a string of its html text. If you don't pass one, the default behavior is to load a
66 		particular file out of the templates directory.
67 
68 		* The next step is transforming the string the TemplateLoader returned into a document object model. This is done
69 		by a private function at this time. If you want to use a different format than HTML, you should either embed the other
70 		language in your template (you can pass a translator to the constructor, details to follow later in this document)
71 
72 		* Next, the contexts must be prepared. It will call [addDefaultFunctions] on each one to prepare them. You can override that
73 		to provide more or fewer functions.
74 
75 		* Now, it is time to expand the template. This is done by a private function, so you cannot replace this step, but you can
76 		customize it in some ways by passing functions to the constructor's `embeddedTagTranslators` argument.
77 
78 		* At this point, it combines the expanded template with the skeleton to form the complete, expanded document.
79 
80 		* Finally, it will call your custom post-processing function right before returning the document. You can override the [postProcess] method to add custom behavior to this step.
81 	)
82 
83 	### Custom Special Tags
84 
85 	You can define translator for special tags, such as to embed a block of custom markup inside your template.
86 
87 	Let's suppose we want to add a `<plaintext>...</plaintext>` tag that does not need HTML entity encoding.
88 
89 	```html
90 		<main>
91 			I can use <b>HTML</b> &amp; need to respect entity encoding here.
92 
93 			<plaintext>
94 				But here, I can write & as plain text and <b>html</b> will not work.
95 			</plaintext>
96 		</main>
97 	```
98 
99 	We can make that possible by defining a custom special tag when constructing the `WebTemplateRenderer`, like this:
100 
101 	---
102 		auto renderer = new WebTemplateRenderer(null /* no special loader needed */, [
103 			// this argument is the special tag name and a function to work with it
104 			// listed as associative arrays.
105 			"plaintext": function(string content, string[string] attributes) {
106 				import arsd.dom;
107 				return WebTemplateRenderer.EmbeddedTagResult(new TextNode(content));
108 			}
109 		]);
110 	---
111 
112 	The associative array keys are the special tag name. For each one, this instructs the HTML parser to treat them similarly to `<script>` - it will read until the closing tag, making no attempt to parse anything else inside it. It just scoops of the content, then calls your function to decide what to do with it.
113 
114 	$(SIDEBAR
115 		Note: just like with how you cannot use `"</script>"` in a Javascript block in HTML, you also need to avoid using the closing tag as a string in your custom thing!
116 	)
117 
118 	Your function is given an associative array of attributes on the special tag and its inner content, as raw source, from the file. You must construct an appropriate DOM element from the content (including possibly a `DocumentFragment` object if you need multiple tags inside) and return it, along with, optionally, an enumerated value telling the renderer if it should try to expand template text inside this new element. If you don't provide a value, it will try to automatically guess what it should do based on the returned element type. (That is, if you return a text node, it will try to do a string-based replacement, and if you return another node, it will descend into it the same as any other node written in the document looking for `AspCode` elements.)
119 
120 	The example given here returns a `TextNode`, so we let it do the default string-based template content processing. But if we returned `WebTemplateRenderer.EmbeddedTagResult(new TextNode(content), false);`, it would not support embedded templates and any `<% .. %>` stuff would be left as-is.
121 
122 	$(TIP
123 		You can trim some of that namespace spam if you make a subclass and pass it to `super` inside your constructor.
124 	)
125 
126 	History:
127 		Added February 5, 2024 (dub v11.5)
128 +/
129 class WebTemplateRenderer {
130 	private TemplateLoader loader;
131 	private EmbeddedTagResult function(string content, string[string] attributes)[string] embeddedTagTranslators;
132 
133 	/++
134 
135 	+/
136 	this(TemplateLoader loader = null, EmbeddedTagResult function(string content, string[string] attributes)[string] embeddedTagTranslators = null) {
137 		if(loader is null)
138 			loader = TemplateLoader.forDirectory("templates/");
139 		this.loader = loader;
140 		this.embeddedTagTranslators = embeddedTagTranslators;
141 	}
142 
143 	/++
144 
145 	+/
146 	struct EmbeddedTagResult {
147 		Element element;
148 		bool scanForTemplateContent = true;
149 	}
150 
151 	/++
152 
153 	+/
154 	final Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null) {
155 		import arsd.cgi;
156 
157 		try {
158 			addDefaultFunctions(context);
159 			addDefaultFunctions(skeletonContext);
160 
161 			if(skeletonName.length == 0)
162 				skeletonName = "skeleton.html";
163 
164 			auto skeleton = parseTemplateString(loader.loadTemplateHtml(skeletonName), WrapTemplateIn.nothing);
165 			auto document = parseTemplateString(loader.loadTemplateHtml(templateName), WrapTemplateIn.rootElement);
166 
167 			expandTemplate(skeleton.root, skeletonContext);
168 
169 			foreach(nav; skeleton.querySelectorAll("nav[data-relative-to]")) {
170 				auto r = nav.getAttribute("data-relative-to");
171 				foreach(a; nav.querySelectorAll("a")) {
172 					a.attrs.href = Uri(a.attrs.href).basedOn(Uri(r));// ~ a.attrs.href;
173 				}
174 			}
175 
176 			expandTemplate(document.root, context);
177 
178 			// also do other unique elements and move them over.
179 			// and have some kind of <document-fragment> that can be just reduced when going out in the final result.
180 
181 			// and try partials.
182 
183 			auto templateMain = document.requireSelector(":root > main");
184 			if(templateMain.hasAttribute("body-class")) {
185 				skeleton.requireSelector("body").addClass(templateMain.getAttribute("body-class"));
186 				templateMain.removeAttribute("body-class");
187 			}
188 
189 			skeleton.requireSelector("main").replaceWith(templateMain.removeFromTree);
190 
191 			if(auto title = document.querySelector(":root > title"))
192 				skeleton.requireSelector(":root > head > title").innerHTML = title.innerHTML;
193 
194 			// also allow top-level unique id replacements
195 			foreach(item; document.querySelectorAll(":root > [id]"))
196 				skeleton.requireElementById(item.id).replaceWith(item.removeFromTree);
197 
198 			foreach(df; skeleton.querySelectorAll("document-fragment"))
199 				df.stripOut();
200 
201 			debug
202 			skeleton.root.prependChild(new HtmlComment(null, templateName ~ " inside skeleton.html"));
203 
204 			postProcess(skeleton);
205 
206 			return skeleton;
207 		} catch(Exception e) {
208 			throw new TemplateException(templateName, context, e);
209 			//throw e;
210 		}
211 	}
212 
213 	private Document parseTemplateString(string templateHtml, WrapTemplateIn wrapTemplateIn) {
214 		auto document = new Document();
215 		document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom
216 		final switch(wrapTemplateIn) {
217 			case WrapTemplateIn.nothing:
218 				// no change needed
219 			break;
220 			case WrapTemplateIn.rootElement:
221 				templateHtml = "<root>" ~ templateHtml ~ "</root>";
222 			break;
223 		}
224 		foreach(k, v; embeddedTagTranslators)
225 			document.rawSourceElements ~= k;
226 		document.parse(templateHtml, true, true);
227 		return document;
228 	}
229 
230 	private enum WrapTemplateIn {
231 		nothing,
232 		rootElement
233 	}
234 
235 	/++
236 		Adds the default functions to the context. You can override this to add additional default functions (or static data) to the context objects.
237 	+/
238 	void addDefaultFunctions(var context) {
239 		import std.conv;
240 		// FIXME: I prolly want it to just set the prototype or something
241 
242 		/+
243 			foo |> filterKeys(["foo", "bar"]);
244 
245 			It needs to match the filter, then if it is -pattern, it is removed and if it is +pattern, it is retained.
246 
247 			First one that matches applies to the key, so the last one in the list is your default.
248 
249 			Default is to reject. Putting a "*" at the end will keep everything not removed though.
250 
251 			["-foo", "*"] // keep everything except foo
252 		+/
253 		context.filterKeys = function var(var f, string[] filters) {
254 			import std.path;
255 			var o = var.emptyObject;
256 			foreach(k, v; f) {
257 				bool keep = false;
258 				foreach(filter; filters) {
259 					if(filter.length == 0)
260 						throw new Exception("invalid filter");
261 					bool filterOff = filter[0] == '-';
262 					if(filterOff)
263 						filter = filter[1 .. $];
264 					if(globMatch(k.get!string, filter)) {
265 						keep = !filterOff;
266 						break;
267 					}
268 				}
269 				if(keep)
270 					o[k] = v;
271 			}
272 			return o;
273 		};
274 
275 		context.encodeURIComponent = function string(var f) {
276 			import arsd.core;
277 			return encodeUriComponent(f.get!string);
278 		};
279 
280 		context.formatDate = function string(string s) {
281 			if(s.length < 10)
282 				return s;
283 			auto year = s[0 .. 4];
284 			auto month = s[5 .. 7];
285 			auto day = s[8 .. 10];
286 
287 			return month ~ "/" ~ day ~ "/" ~ year;
288 		};
289 
290 		context.dayOfWeek = function string(string s) {
291 			import std.datetime;
292 			return daysOfWeekFullNames[Date.fromISOExtString(s[0 .. 10]).dayOfWeek];
293 		};
294 
295 		context.formatTime = function string(string s) {
296 			if(s.length < 20)
297 				return s;
298 			auto hour = s[11 .. 13].to!int;
299 			auto minutes = s[14 .. 16].to!int;
300 			auto seconds = s[17 .. 19].to!int;
301 
302 			auto am = (hour >= 12) ? "PM" : "AM";
303 			if(hour > 12)
304 				hour -= 12;
305 
306 			return hour.to!string ~ (minutes < 10 ? ":0" : ":") ~ minutes.to!string ~ " " ~ am;
307 		};
308 
309 		// don't want checking meta or data to be an error
310 		if(context.meta == null)
311 			context.meta = var.emptyObject;
312 		if(context.data == null)
313 			context.data = var.emptyObject;
314 	}
315 
316 	/++
317 		The default is currently to do nothing. This function only exists for you to override it.
318 
319 		However, this may change in the future. To protect yourself, if you subclass and override
320 		this method, always call `super.postProcess(document);` before doing your own customizations.
321 	+/
322 	void postProcess(Document document) {
323 
324 	}
325 
326 	private void expandTemplate(Element root, var context) {
327 		import std.string;
328 
329 		string replaceThingInString(string v) {
330 			auto idx = v.indexOf("<%=");
331 			if(idx == -1)
332 				return v;
333 			auto n = v[0 .. idx];
334 			auto r = v[idx + "<%=".length .. $];
335 
336 			auto end = r.indexOf("%>");
337 			if(end == -1)
338 				throw new Exception("unclosed asp code in attribute");
339 			auto code = r[0 .. end];
340 			r = r[end + "%>".length .. $];
341 
342 			import arsd.script;
343 			auto res = interpret(code, context).get!string;
344 
345 			return n ~ res ~ replaceThingInString(r);
346 		}
347 
348 		foreach(k, v; root.attributes) {
349 			if(k == "onrender") {
350 				continue;
351 			}
352 
353 			v = replaceThingInString(v);
354 
355 			root.setAttribute(k, v);
356 		}
357 
358 		bool lastBoolResult;
359 
360 		foreach(ele; root.children) {
361 			if(ele.tagName == "if-true") {
362 				auto fragment = new DocumentFragment(null);
363 				import arsd.script;
364 				auto got = interpret(ele.attrs.cond, context).opCast!bool;
365 				if(got) {
366 					ele.tagName = "root";
367 					expandTemplate(ele, context);
368 					fragment.stealChildren(ele);
369 				}
370 				lastBoolResult = got;
371 				ele.replaceWith(fragment);
372 			} else if(ele.tagName == "or-else") {
373 				auto fragment = new DocumentFragment(null);
374 				if(!lastBoolResult) {
375 					ele.tagName = "root";
376 					expandTemplate(ele, context);
377 					fragment.stealChildren(ele);
378 				}
379 				ele.replaceWith(fragment);
380 			} else if(ele.tagName == "for-each") {
381 				auto fragment = new DocumentFragment(null);
382 				var nc = var.emptyObject(context);
383 				lastBoolResult = false;
384 				auto got = interpret(ele.attrs.over, context);
385 				foreach(k, item; got) {
386 					lastBoolResult = true;
387 					nc[ele.attrs.as] = item;
388 					if(ele.attrs.index.length)
389 						nc[ele.attrs.index] = k;
390 					auto clone = ele.cloneNode(true);
391 					clone.tagName = "root"; // it certainly isn't a for-each anymore!
392 					expandTemplate(clone, nc);
393 
394 					fragment.stealChildren(clone);
395 				}
396 				ele.replaceWith(fragment);
397 			} else if(ele.tagName == "render-template") {
398 				import std.file;
399 				auto templateName = ele.getAttribute("file");
400 				auto document = new Document();
401 				document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom
402 				document.parse("<root>" ~ loader.loadTemplateHtml(templateName) ~ "</root>", true, true);
403 
404 				var obj = var.emptyObject;
405 				obj.prototype = context;
406 
407 				// FIXME: there might be other data you pass from the parent...
408 				if(auto data = ele.getAttribute("data")) {
409 					obj["data"] = var.fromJson(data);
410 				}
411 
412 				expandTemplate(document.root, obj);
413 
414 				auto fragment = new DocumentFragment(null);
415 
416 				debug fragment.appendChild(new HtmlComment(null, templateName));
417 				fragment.stealChildren(document.root);
418 				debug fragment.appendChild(new HtmlComment(null, "end " ~ templateName));
419 
420 				ele.replaceWith(fragment);
421 			} else if(ele.tagName == "hidden-form-data") {
422 				auto from = interpret(ele.attrs.from, context);
423 				auto name = ele.attrs.name;
424 
425 				auto form = new Form(null);
426 
427 				populateForm(form, from, name);
428 
429 				auto fragment = new DocumentFragment(null);
430 				fragment.stealChildren(form);
431 
432 				ele.replaceWith(fragment);
433 			} else if(auto asp = cast(AspCode) ele) {
434 				auto code = asp.source[1 .. $-1];
435 				auto fragment = new DocumentFragment(null);
436 				if(code[0] == '=') {
437 					import arsd.script;
438 					if(code.length > 5 && code[1 .. 5] == "HTML") {
439 						auto got = interpret(code[5 .. $], context);
440 						if(auto native = got.getWno!Element)
441 							fragment.appendChild(native);
442 						else
443 							fragment.innerHTML = got.get!string;
444 					} else {
445 						auto got = interpret(code[1 .. $], context).get!string;
446 						fragment.innerText = got;
447 					}
448 				}
449 				asp.replaceWith(fragment);
450 			} else if(ele.tagName == "script") {
451 				auto source = ele.innerHTML;
452 				string newCode;
453 				check_more:
454 				auto idx = source.indexOf("<%=");
455 				if(idx != -1) {
456 					newCode ~= source[0 .. idx];
457 					auto remaining = source[idx + 3 .. $];
458 					idx = remaining.indexOf("%>");
459 					if(idx == -1)
460 						throw new Exception("unclosed asp code in script");
461 					auto code = remaining[0 .. idx];
462 
463 					auto data = interpret(code, context);
464 					newCode ~= data.toJson();
465 
466 					source = remaining[idx + 2 .. $];
467 					goto check_more;
468 				}
469 
470 				if(newCode is null)
471 					{} // nothing needed
472 				else {
473 					newCode ~= source;
474 					ele.innerRawSource = newCode;
475 				}
476 			} else if(auto pTranslator = ele.tagName in embeddedTagTranslators) {
477 				auto replacement = (*pTranslator)(ele.innerHTML, ele.attributes);
478 				if(replacement.element is null)
479 					ele.stripOut();
480 				else {
481 					ele.replaceWith(replacement.element);
482 					if(replacement.scanForTemplateContent) {
483 						if(auto tn = cast(TextNode) replacement.element)
484 							tn.textContent = replaceThingInString(tn.nodeValue);
485 						else
486 							expandTemplate(replacement.element, context);
487 					}
488 				}
489 			} else {
490 				expandTemplate(ele, context);
491 			}
492 		}
493 
494 		if(root.hasAttribute("onrender")) {
495 			var nc = var.emptyObject(context);
496 			nc["this"] = wrapNativeObject(root);
497 			nc["this"]["populateFrom"] = delegate var(var this_, var[] args) {
498 				auto form = cast(Form) root;
499 				if(form is null) return this_;
500 				foreach(k, v; args[0]) {
501 					populateForm(form, v, k.get!string);
502 				}
503 				return this_;
504 			};
505 			interpret(root.getAttribute("onrender"), nc);
506 
507 			root.removeAttribute("onrender");
508 		}
509 	}
510 }
511 
512 /+
513 unittest {
514 
515 }
516 +/
517 
518 deprecated("Use a WebTemplateRenderer class instead")
519 void addDefaultFunctions(var context) {
520 	scope renderer = new WebTemplateRenderer(null);
521 	renderer.addDefaultFunctions(context);
522 }
523 
524 
525 // FIXME: want to show additional info from the exception, neatly integrated, whenever possible.
526 class TemplateException : Exception {
527 	string templateName;
528 	var context;
529 	Exception e;
530 	this(string templateName, var context, Exception e) {
531 		this.templateName = templateName;
532 		this.context = context;
533 		this.e = e;
534 
535 		super("Exception in template " ~ templateName ~ ": " ~ e.msg);
536 	}
537 }
538 
539 /++
540 	A loader object for reading raw template, so you can use something other than files if you like.
541 
542 	See [TemplateLoader.forDirectory] to a pre-packaged class that implements a loader for a particular directory.
543 
544 	History:
545 		Added December 11, 2023 (dub v11.3)
546 +/
547 interface TemplateLoader {
548 	/++
549 		This is the main method to look up a template name and return its HTML as a string.
550 
551 		Typical implementation is to just `return std.file.readText(directory ~ name);`
552 	+/
553 	string loadTemplateHtml(string name);
554 
555 	/++
556 		Returns a loader for files in the given directory.
557 	+/
558 	static TemplateLoader forDirectory(string directoryName) {
559 		if(directoryName.length && directoryName[$-1] != '/')
560 			directoryName ~= "/";
561 
562 		return new class TemplateLoader {
563 			string loadTemplateHtml(string name) {
564 				import std.file;
565 				return readText(directoryName ~ name);
566 			}
567 		};
568 	}
569 }
570 
571 /++
572 	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.
573 
574 	Parameters:
575 		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)
576 		context = the global object available to scripts inside the template
577 		skeletonContext = the global object available to the skeleton template
578 		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
579 		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.
580 
581 	History:
582 		Parameter `loader` was added on December 11, 2023 (dub v11.3)
583 
584 	See_Also:
585 		[WebTemplateRenderer] gives you more control than the argument list here provides.
586 +/
587 Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null, TemplateLoader loader = null) {
588 	scope auto renderer = new WebTemplateRenderer(loader);
589 	return renderer.renderTemplate(templateName, context, skeletonContext, skeletonName);
590 }
591 
592 /++
593 	Shows how top-level things from the template are moved to their corresponding items on the skeleton.
594 +/
595 unittest {
596 	// for the unittest, we want to inject a loader that uses plain strings instead of files.
597 	auto testLoader = new class TemplateLoader {
598 		string loadTemplateHtml(string name) {
599 			switch(name) {
600 				case "skeleton":
601 					return `
602 						<html>
603 							<head>
604 								<!-- you can define replaceable things with ids -->
605 								<!-- including <document-fragment>s which are stripped out when the template is finalized -->
606 								<document-fragment id="header-stuff" />
607 							</head>
608 							<body>
609 								<main></main>
610 							</body>
611 						</html>
612 					`;
613 				case "main":
614 					return `
615 						<main>Hello</main>
616 						<document-fragment id="header-stuff">
617 							<title>My title</title>
618 						</document-fragment>
619 					`;
620 				default: assert(0);
621 			}
622 		}
623 	};
624 
625 	Document doc = renderTemplate("main", var.emptyObject, var.emptyObject, "skeleton", testLoader);
626 
627 	assert(doc.querySelector("document-fragment") is null); // the <document-fragment> items are stripped out
628 	assert(doc.querySelector("title") !is null); // but the stuff from inside it is brought in
629 	assert(doc.requireSelector("main").textContent == "Hello"); // and the main from the template is moved to the skeelton
630 }
631 
632 void populateForm(Form form, var obj, string name) {
633 	import std.string;
634 
635 	if(obj.payloadType == var.Type.Object) {
636 		form.setValue(name, "");
637 		foreach(k, v; obj) {
638 			auto fn = name.replace("%", k.get!string);
639 			// should I unify structs and assoctiavite arrays?
640 			populateForm(form, v, fn ~ "["~k.get!string~"]");
641 			//populateForm(form, v, fn ~"."~k.get!string);
642 		}
643 	} else {
644 		//import std.stdio; writeln("SET ", name, " ", obj, " ", obj.payloadType);
645 		form.setValue(name, obj.get!string);
646 	}
647 
648 }
649 
650 /++
651 	Replaces `things[0]` with `things[1]` in `what` all at once.
652 	Returns the new string.
653 
654 	History:
655 		Added February 12, 2022. I might move it later.
656 +/
657 string multiReplace(string what, string[] things...) {
658 	import std.string; // FIXME: indexOf not actually ideal but meh
659 	if(things.length == 0)
660 		return what;
661 
662 	assert(things.length % 2 == 0);
663 
664 	string n;
665 
666 	while(what.length) {
667 		int nextIndex = cast(int) what.length;
668 		int nextThing = -1;
669 
670 		foreach(i, thing; things) {
671 			if(i & 1)
672 				continue;
673 
674 			auto idx = what.indexOf(thing);
675 			if(idx != -1 && idx < nextIndex) {
676 				nextIndex = cast(int) idx;
677 				nextThing = cast(int) i;
678 			}
679 		}
680 
681 		if(nextThing == -1) {
682 			n ~= what;
683 			what = null;
684 		} else {
685 			n ~= what[0 .. nextIndex];
686 			what = what[nextIndex + things[nextThing].length .. $];
687 			n ~= things[nextThing + 1];
688 			continue;
689 		}
690 	}
691 
692 	return n;
693 }
694 
695 immutable daysOfWeekFullNames = [
696 	"Sunday",
697 	"Monday",
698 	"Tuesday",
699 	"Wednesday",
700 	"Thursday",
701 	"Friday",
702 	"Saturday"
703 ];
704 
705 /++
706 	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.
707 
708 	Inside the template, the value returned by the function will be available in the context as the variable `data`.
709 +/
710 struct Template {
711 	string name;
712 }
713 /++
714 	UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides the default template skeleton file name.
715 +/
716 struct Skeleton {
717 	string name;
718 }
719 
720 /++
721 	UDA to attach runtime metadata to a function. Will be available in the template.
722 
723 	History:
724 		Added July 12, 2021
725 +/
726 struct meta {
727 	string name;
728 	string value;
729 }
730 
731 /++
732 	Can be used as a return value from one of your own methods when rendering websites with [WebPresenterWithTemplateSupport].
733 +/
734 struct RenderTemplate {
735 	this(string name, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null) {
736 		this.name = name;
737 		this.context = context;
738 		this.skeletonContext = skeletonContext;
739 		this.skeletonName = skeletonName;
740 	}
741 
742 	string name;
743 	var context;
744 	var skeletonContext;
745 	string skeletonName;
746 }
747 
748 
749 /++
750 	Make a class that inherits from this with your further customizations, or minimally:
751 	---
752 	class MyPresenter : WebPresenterWithTemplateSupport!MyPresenter { }
753 	---
754 +/
755 template WebPresenterWithTemplateSupport(CTRP) {
756 	import arsd.cgi;
757 	class WebPresenterWithTemplateSupport : WebPresenter!(CTRP) {
758 		override Element htmlContainer() {
759 			try {
760 				auto skeleton = renderTemplate("generic.html", var.emptyObject, var.emptyObject, "skeleton.html", templateLoader());
761 				return skeleton.requireSelector("main");
762 			} catch(Exception e) {
763 				auto document = new Document("<html><body><p>generic.html trouble: <span id=\"ghe\"></span></p> <main></main></body></html>");
764 				document.requireSelector("#ghe").textContent = e.msg;
765 				return document.requireSelector("main");
766 			}
767 		}
768 
769 		static struct Meta {
770 			typeof(null) at;
771 			string templateName;
772 			string skeletonName;
773 			string[string] meta;
774 			Form function(WebPresenterWithTemplateSupport presenter) automaticForm;
775 			alias at this;
776 		}
777 		template methodMeta(alias method) {
778 			static Meta helper() {
779 				Meta ret;
780 
781 				// ret.at = typeof(super).methodMeta!method;
782 
783 				foreach(attr; __traits(getAttributes, method))
784 					static if(is(typeof(attr) == Template))
785 						ret.templateName = attr.name;
786 					else static if(is(typeof(attr) == Skeleton))
787 						ret.skeletonName = attr.name;
788 					else static if(is(typeof(attr) == .meta))
789 						ret.meta[attr.name] = attr.value;
790 
791 				ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) {
792 					return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null);
793 				};
794 
795 				return ret;
796 			}
797 			enum methodMeta = helper();
798 		}
799 
800 		/// You can override this
801 		void addContext(Cgi cgi, var ctx) {}
802 
803 		/++
804 			You can override this. The default is "templates/". Your returned string must end with '/'.
805 			(in future versions it will probably allow a null return too, but right now it must be a /).
806 
807 			History:
808 				Added December 6, 2023 (dub v11.3)
809 		+/
810 		TemplateLoader templateLoader() {
811 			return null;
812 		}
813 
814 		/++
815 			You can override this.
816 
817 			History:
818 				Added February 5, 2024 (dub v11.5)
819 		+/
820 		WebTemplateRenderer webTemplateRenderer() {
821 			return new WebTemplateRenderer(templateLoader());
822 		}
823 
824 		void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) {
825 			addContext(cgi, ret.context);
826 
827 			auto renderer = this.webTemplateRenderer();
828 
829 			auto skeleton = renderer.renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName);
830 			cgi.setResponseContentType("text/html; charset=utf8");
831 			cgi.gzipResponse = true;
832 			cgi.write(skeleton.toString(), true);
833 		}
834 
835 		void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) {
836 			if(meta.templateName.length) {
837 				var sobj = var.emptyObject;
838 
839 				var obj = var.emptyObject;
840 
841 				obj.data = ret;
842 
843 				/+
844 				sobj.meta = var.emptyObject;
845 				foreach(k,v; meta.meta)
846 					sobj.meta[k] = v;
847 				+/
848 
849 				obj.meta = var.emptyObject;
850 				foreach(k,v; meta.meta)
851 					obj.meta[k] = v;
852 
853 				obj.meta.currentPath = cgi.pathInfo;
854 				obj.meta.automaticForm = { return meta.automaticForm(this).toString; };
855 
856 				presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta);
857 			} else
858 				super.presentSuccessfulReturnAsHtml(cgi, ret, meta);
859 		}
860 	}
861 }
862 
863 WebTemplateRenderer DefaultWtrFactory(TemplateLoader loader) {
864 	return new WebTemplateRenderer(loader);
865 }
866 
867 /++
868 	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.
869 
870 	Parameters:
871 		urlPrefix = the url prefix to trigger this handler, relative to the current dispatcher base
872 		directory = the directory, under the template directory, to find the template files
873 		skeleton = the name of the skeleton file inside the template directory
874 		extension = the file extension to add to the url name to get the template name
875 		wtrFactory = an alias to a function of type `WebTemplateRenderer function(TemplateLoader loader)` that returns `new WebTemplateRenderer(loader)` (or similar subclasses/argument lists);
876 
877 	To get the filename of the template from the url, it will:
878 
879 	1) Strip the url prefixes off to get just the filename
880 
881 	2) Concatenate the directory with the template directory
882 
883 	3) Add the extension to the givenname
884 
885 	$(PITFALL
886 		The `templateDirectory` parameter may be removed or changed in the near future.
887 	)
888 
889 	History:
890 		Added July 28, 2021 (documented dub v11.0)
891 
892 		The `wtrFactory` parameter was added on February 5, 2024 (dub v11.5).
893 +/
894 auto serveTemplateDirectory(alias wtrFactory = DefaultWtrFactory)(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html", string templateDirectory = "templates/") {
895 	import arsd.cgi;
896 	import std.file;
897 
898 	assert(urlPrefix[0] == '/');
899 	assert(urlPrefix[$-1] == '/');
900 
901 	assert(templateDirectory[$-1] == '/');
902 
903 	static struct DispatcherDetails {
904 		string directory;
905 		string skeleton;
906 		string extension;
907 		string templateDirectory;
908 	}
909 
910 	if(directory is null)
911 		directory = urlPrefix[1 .. $];
912 
913 	if(directory.length == 0)
914 		directory = "./";
915 
916 	assert(directory[$-1] == '/');
917 
918 	static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) {
919 		auto file = cgi.pathInfo[urlPrefix.length .. $];
920 		if(file.indexOf("/") != -1 || file.indexOf("\\") != -1)
921 			return false;
922 
923 		auto fn = details.templateDirectory ~ details.directory ~ file ~ details.extension;
924 		if(std.file.exists(fn)) {
925 			cgi.setResponseExpiresRelative(600, true); // 10 minute cache expiration by default, FIXME it should be configurable
926 
927 			auto loader = TemplateLoader.forDirectory(details.templateDirectory);
928 
929 			WebTemplateRenderer renderer = wtrFactory(loader);
930 
931 			auto doc = renderer.renderTemplate(fn[details.templateDirectory.length.. $], var.emptyObject, var.emptyObject, details.skeleton);
932 			cgi.gzipResponse = true;
933 			cgi.write(doc.toString, true);
934 			return true;
935 		} else {
936 			return false;
937 		}
938 	}
939 
940 	return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension, templateDirectory));
941 }