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