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 			<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 // FIXME: want to show additional info from the exception, neatly integrated, whenever possible.
58 class TemplateException : Exception {
59 	string templateName;
60 	var context;
61 	Exception e;
62 	this(string templateName, var context, Exception e) {
63 		this.templateName = templateName;
64 		this.context = context;
65 		this.e = e;
66 
67 		super("Exception in template " ~ templateName ~ ": " ~ e.msg);
68 	}
69 }
70 
71 void addDefaultFunctions(var context) {
72 	import std.conv;
73 	// FIXME: I prolly want it to just set the prototype or something
74 
75 	/+
76 		foo |> filterKeys(["foo", "bar"]);
77 
78 		It needs to match the filter, then if it is -pattern, it is removed and if it is +pattern, it is retained.
79 
80 		First one that matches applies to the key, so the last one in the list is your default.
81 
82 		Default is to reject. Putting a "*" at the end will keep everything not removed though.
83 
84 		["-foo", "*"] // keep everything except foo
85 	+/
86 	context.filterKeys = function var(var f, string[] filters) {
87 		import std.path;
88 		var o = var.emptyObject;
89 		foreach(k, v; f) {
90 			bool keep = false;
91 			foreach(filter; filters) {
92 				if(filter.length == 0)
93 					throw new Exception("invalid filter");
94 				bool filterOff = filter[0] == '-';
95 				if(filterOff)
96 					filter = filter[1 .. $];
97 				if(globMatch(k.get!string, filter)) {
98 					keep = !filterOff;
99 					break;
100 				}
101 			}
102 			if(keep)
103 				o[k] = v;
104 		}
105 		return o;
106 	};
107 
108 	context.encodeURIComponent = function string(var f) {
109 		import std.uri;
110 		return encodeComponent(f.get!string);
111 	};
112 
113 	context.formatDate = function string(string s) {
114 		if(s.length < 10)
115 			return s;
116 		auto year = s[0 .. 4];
117 		auto month = s[5 .. 7];
118 		auto day = s[8 .. 10];
119 
120 		return month ~ "/" ~ day ~ "/" ~ year;
121 	};
122 
123 	context.dayOfWeek = function string(string s) {
124 		import std.datetime;
125 		return daysOfWeekFullNames[Date.fromISOExtString(s[0 .. 10]).dayOfWeek];
126 	};
127 
128 	context.formatTime = function string(string s) {
129 		if(s.length < 20)
130 			return s;
131 		auto hour = s[11 .. 13].to!int;
132 		auto minutes = s[14 .. 16].to!int;
133 		auto seconds = s[17 .. 19].to!int;
134 
135 		auto am = (hour >= 12) ? "PM" : "AM";
136 		if(hour > 12)
137 			hour -= 12;
138 
139 		return hour.to!string ~ (minutes < 10 ? ":0" : ":") ~ minutes.to!string ~ " " ~ am;
140 	};
141 
142 	// don't want checking meta or data to be an error
143 	if(context.meta == null)
144 		context.meta = var.emptyObject;
145 	if(context.data == null)
146 		context.data = var.emptyObject;
147 }
148 
149 /++
150 	A loader object for reading raw template, so you can use something other than files if you like.
151 
152 	See [TemplateLoader.forDirectory] to a pre-packaged class that implements a loader for a particular directory.
153 
154 	History:
155 		Added December 11, 2023 (dub v11.3)
156 +/
157 interface TemplateLoader {
158 	/++
159 		This is the main method to look up a template name and return its HTML as a string.
160 
161 		Typical implementation is to just `return std.file.readText(directory ~ name);`
162 	+/
163 	string loadTemplateHtml(string name);
164 
165 	/++
166 		Returns a loader for files in the given directory.
167 	+/
168 	static TemplateLoader forDirectory(string directoryName) {
169 		if(directoryName.length && directoryName[$-1] != '/')
170 			directoryName ~= "/";
171 
172 		return new class TemplateLoader {
173 			string loadTemplateHtml(string name) {
174 				import std.file;
175 				return readText(directoryName ~ name);
176 			}
177 		};
178 	}
179 }
180 
181 /++
182 	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.
183 
184 	Parameters:
185 		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)
186 		context = the global object available to scripts inside the template
187 		skeletonContext = the global object available to the skeleton template
188 		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
189 		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.
190 
191 	History:
192 		Parameter `loader` was added on December 11, 2023 (dub v11.3)
193 +/
194 Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null, TemplateLoader loader = null) {
195 	import arsd.cgi;
196 
197 	if(loader is null)
198 		loader = TemplateLoader.forDirectory("templates/");
199 
200 	try {
201 		addDefaultFunctions(context);
202 		addDefaultFunctions(skeletonContext);
203 
204 		if(skeletonName.length == 0)
205 			skeletonName = "skeleton.html";
206 
207 		auto skeleton = new Document(loader.loadTemplateHtml(skeletonName), true, true);
208 		auto document = new Document();
209 		document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom
210 		document.parse("<root>" ~ loader.loadTemplateHtml(templateName) ~ "</root>", true, true);
211 
212 		expandTemplate(skeleton.root, skeletonContext, loader);
213 
214 		foreach(nav; skeleton.querySelectorAll("nav[data-relative-to]")) {
215 			auto r = nav.getAttribute("data-relative-to");
216 			foreach(a; nav.querySelectorAll("a")) {
217 				a.attrs.href = Uri(a.attrs.href).basedOn(Uri(r));// ~ a.attrs.href;
218 			}
219 		}
220 
221 		expandTemplate(document.root, context, loader);
222 
223 		// also do other unique elements and move them over.
224 		// and have some kind of <document-fragment> that can be just reduced when going out in the final result.
225 
226 		// and try partials.
227 
228 		auto templateMain = document.requireSelector(":root > main");
229 		if(templateMain.hasAttribute("body-class")) {
230 			skeleton.requireSelector("body").addClass(templateMain.getAttribute("body-class"));
231 			templateMain.removeAttribute("body-class");
232 		}
233 
234 		skeleton.requireSelector("main").replaceWith(templateMain.removeFromTree);
235 
236 		if(auto title = document.querySelector(":root > title"))
237 			skeleton.requireSelector(":root > head > title").innerHTML = title.innerHTML;
238 
239 		// also allow top-level unique id replacements
240 		foreach(item; document.querySelectorAll(":root > [id]"))
241 			skeleton.requireElementById(item.id).replaceWith(item.removeFromTree);
242 
243 		foreach(df; skeleton.querySelectorAll("document-fragment"))
244 			df.stripOut();
245 
246 		debug
247 		skeleton.root.prependChild(new HtmlComment(null, templateName ~ " inside skeleton.html"));
248 
249 		return skeleton;
250 	} catch(Exception e) {
251 		throw new TemplateException(templateName, context, e);
252 		//throw e;
253 	}
254 }
255 
256 /++
257 	Shows how top-level things from the template are moved to their corresponding items on the  skeleton.
258 +/
259 unittest {
260 	// for the unittest, we want to inject a loader that uses plain strings instead of files.
261 	auto testLoader = new class TemplateLoader {
262 		string loadTemplateHtml(string name) {
263 			switch(name) {
264 				case "skeleton":
265 					return `
266 						<html>
267 							<head>
268 								<!-- you can define replaceable things with ids -->
269 								<!-- including <document-fragment>s which are stripped out when the template is finalized -->
270 								<document-fragment id="header-stuff" />
271 							</head>
272 							<body>
273 								<main></main>
274 							</body>
275 						</html>
276 					`;
277 				case "main":
278 					return `
279 						<main>Hello</main>
280 						<document-fragment id="header-stuff">
281 							<title>My title</title>
282 						</document-fragment>
283 					`;
284 				default: assert(0);
285 			}
286 		}
287 	};
288 
289 	Document doc = renderTemplate("main", var.emptyObject, var.emptyObject, "skeleton", testLoader);
290 
291 	assert(doc.querySelector("document-fragment") is null); // the <document-fragment> items are stripped out
292 	assert(doc.querySelector("title") !is null); // but the stuff from inside it is brought in
293 	assert(doc.requireSelector("main").textContent == "Hello"); // and the main from the template is moved to the skeelton
294 }
295 
296 private void expandTemplate(Element root, var context, TemplateLoader loader) {
297 	import std.string;
298 
299 	string replaceThingInString(string v) {
300 		auto idx = v.indexOf("<%=");
301 		if(idx == -1)
302 			return v;
303 		auto n = v[0 .. idx];
304 		auto r = v[idx + "<%=".length .. $];
305 
306 		auto end = r.indexOf("%>");
307 		if(end == -1)
308 			throw new Exception("unclosed asp code in attribute");
309 		auto code = r[0 .. end];
310 		r = r[end + "%>".length .. $];
311 
312 		import arsd.script;
313 		auto res = interpret(code, context).get!string;
314 
315 		return n ~ res ~ replaceThingInString(r);
316 	}
317 
318 	foreach(k, v; root.attributes) {
319 		if(k == "onrender") {
320 			continue;
321 		}
322 
323 		v = replaceThingInString(v);
324 
325 		root.setAttribute(k, v);
326 	}
327 
328 	bool lastBoolResult;
329 
330 	foreach(ele; root.children) {
331 		if(ele.tagName == "if-true") {
332 			auto fragment = new DocumentFragment(null);
333 			import arsd.script;
334 			auto got = interpret(ele.attrs.cond, context).opCast!bool;
335 			if(got) {
336 				ele.tagName = "root";
337 				expandTemplate(ele, context, loader);
338 				fragment.stealChildren(ele);
339 			}
340 			lastBoolResult = got;
341 			ele.replaceWith(fragment);
342 		} else if(ele.tagName == "or-else") {
343 			auto fragment = new DocumentFragment(null);
344 			if(!lastBoolResult) {
345 				ele.tagName = "root";
346 				expandTemplate(ele, context, loader);
347 				fragment.stealChildren(ele);
348 			}
349 			ele.replaceWith(fragment);
350 		} else if(ele.tagName == "for-each") {
351 			auto fragment = new DocumentFragment(null);
352 			var nc = var.emptyObject(context);
353 			lastBoolResult = false;
354 			auto got = interpret(ele.attrs.over, context);
355 			foreach(k, item; got) {
356 				lastBoolResult = true;
357 				nc[ele.attrs.as] = item;
358 				if(ele.attrs.index.length)
359 					nc[ele.attrs.index] = k;
360 				auto clone = ele.cloneNode(true);
361 				clone.tagName = "root"; // it certainly isn't a for-each anymore!
362 				expandTemplate(clone, nc, loader);
363 
364 				fragment.stealChildren(clone);
365 			}
366 			ele.replaceWith(fragment);
367 		} else if(ele.tagName == "render-template") {
368 			import std.file;
369 			auto templateName = ele.getAttribute("file");
370 			auto document = new Document();
371 			document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom
372 			document.parse("<root>" ~ loader.loadTemplateHtml(templateName) ~ "</root>", true, true);
373 
374 			var obj = var.emptyObject;
375 			obj.prototype = context;
376 
377 			// FIXME: there might be other data you pass from the parent...
378 			if(auto data = ele.getAttribute("data")) {
379 				obj["data"] = var.fromJson(data);
380 			}
381 
382 			expandTemplate(document.root, obj, loader);
383 
384 			auto fragment = new DocumentFragment(null);
385 
386 			debug fragment.appendChild(new HtmlComment(null, templateName));
387 			fragment.stealChildren(document.root);
388 			debug fragment.appendChild(new HtmlComment(null, "end " ~ templateName));
389 
390 			ele.replaceWith(fragment);
391 		} else if(ele.tagName == "hidden-form-data") {
392 			auto from = interpret(ele.attrs.from, context);
393 			auto name = ele.attrs.name;
394 
395 			auto form = new Form(null);
396 
397 			populateForm(form, from, name);
398 
399 			auto fragment = new DocumentFragment(null);
400 			fragment.stealChildren(form);
401 
402 			ele.replaceWith(fragment);
403 		} else if(auto asp = cast(AspCode) ele) {
404 			auto code = asp.source[1 .. $-1];
405 			auto fragment = new DocumentFragment(null);
406 			if(code[0] == '=') {
407 				import arsd.script;
408 				if(code.length > 5 && code[1 .. 5] == "HTML") {
409 					auto got = interpret(code[5 .. $], context);
410 					if(auto native = got.getWno!Element)
411 						fragment.appendChild(native);
412 					else
413 						fragment.innerHTML = got.get!string;
414 				} else {
415 					auto got = interpret(code[1 .. $], context).get!string;
416 					fragment.innerText = got;
417 				}
418 			}
419 			asp.replaceWith(fragment);
420 		} else if(ele.tagName == "script") {
421 			auto source = ele.innerHTML;
422 			string newCode;
423 			check_more:
424 			auto idx = source.indexOf("<%=");
425 			if(idx != -1) {
426 				newCode ~= source[0 .. idx];
427 				auto remaining = source[idx + 3 .. $];
428 				idx = remaining.indexOf("%>");
429 				if(idx == -1)
430 					throw new Exception("unclosed asp code in script");
431 				auto code = remaining[0 .. idx];
432 
433 				auto data = interpret(code, context);
434 				newCode ~= data.toJson();
435 
436 				source = remaining[idx + 2 .. $];
437 				goto check_more;
438 			}
439 
440 			if(newCode is null)
441 				{} // nothing needed
442 			else {
443 				newCode ~= source;
444 				ele.innerRawSource = newCode;
445 			}
446 		} else {
447 			expandTemplate(ele, context, loader);
448 		}
449 	}
450 
451 	if(root.hasAttribute("onrender")) {
452 		var nc = var.emptyObject(context);
453 		nc["this"] = wrapNativeObject(root);
454 		nc["this"]["populateFrom"] = delegate var(var this_, var[] args) {
455 			auto form = cast(Form) root;
456 			if(form is null) return this_;
457 			foreach(k, v; args[0]) {
458 				populateForm(form, v, k.get!string);
459 			}
460 			return this_;
461 		};
462 		interpret(root.getAttribute("onrender"), nc);
463 
464 		root.removeAttribute("onrender");
465 	}
466 }
467 
468 void populateForm(Form form, var obj, string name) {
469 	import std.string;
470 
471 	if(obj.payloadType == var.Type.Object) {
472 		form.setValue(name, "");
473 		foreach(k, v; obj) {
474 			auto fn = name.replace("%", k.get!string);
475 			// should I unify structs and assoctiavite arrays?
476 			populateForm(form, v, fn ~ "["~k.get!string~"]");
477 			//populateForm(form, v, fn ~"."~k.get!string);
478 		}
479 	} else {
480 		//import std.stdio; writeln("SET ", name, " ", obj, " ", obj.payloadType);
481 		form.setValue(name, obj.get!string);
482 	}
483 
484 }
485 
486 /++
487 	Replaces `things[0]` with `things[1]` in `what` all at once.
488 	Returns the new string.
489 
490 	History:
491 		Added February 12, 2022. I might move it later.
492 +/
493 string multiReplace(string what, string[] things...) {
494 	import std.string; // FIXME: indexOf not actually ideal but meh
495 	if(things.length == 0)
496 		return what;
497 
498 	assert(things.length % 2 == 0);
499 
500 	string n;
501 
502 	while(what.length) {
503 		int nextIndex = cast(int) what.length;
504 		int nextThing = -1;
505 
506 		foreach(i, thing; things) {
507 			if(i & 1)
508 				continue;
509 
510 			auto idx = what.indexOf(thing);
511 			if(idx != -1 && idx < nextIndex) {
512 				nextIndex = cast(int) idx;
513 				nextThing = cast(int) i;
514 			}
515 		}
516 
517 		if(nextThing == -1) {
518 			n ~= what;
519 			what = null;
520 		} else {
521 			n ~= what[0 .. nextIndex];
522 			what = what[nextIndex + things[nextThing].length .. $];
523 			n ~= things[nextThing + 1];
524 			continue;
525 		}
526 	}
527 
528 	return n;
529 }
530 
531 immutable daysOfWeekFullNames = [
532 	"Sunday",
533 	"Monday",
534 	"Tuesday",
535 	"Wednesday",
536 	"Thursday",
537 	"Friday",
538 	"Saturday"
539 ];
540 
541 /++
542 	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.
543 
544 	Inside the template, the value returned by the function will be available in the context as the variable `data`.
545 +/
546 struct Template {
547 	string name;
548 }
549 /++
550 	UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides the default template skeleton file name.
551 +/
552 struct Skeleton {
553 	string name;
554 }
555 
556 /++
557 	UDA to attach runtime metadata to a function. Will be available in the template.
558 
559 	History:
560 		Added July 12, 2021
561 +/
562 struct meta {
563 	string name;
564 	string value;
565 }
566 
567 /++
568 	Can be used as a return value from one of your own methods when rendering websites with [WebPresenterWithTemplateSupport].
569 +/
570 struct RenderTemplate {
571 	string name;
572 	var context = var.emptyObject;
573 	var skeletonContext = var.emptyObject;
574 	string skeletonName;
575 }
576 
577 
578 /++
579 	Make a class that inherits from this with your further customizations, or minimally:
580 	---
581 	class MyPresenter : WebPresenterWithTemplateSupport!MyPresenter { }
582 	---
583 +/
584 template WebPresenterWithTemplateSupport(CTRP) {
585 	import arsd.cgi;
586 	class WebPresenterWithTemplateSupport : WebPresenter!(CTRP) {
587 		override Element htmlContainer() {
588 			try {
589 				auto skeleton = renderTemplate("generic.html", var.emptyObject, var.emptyObject, "skeleton.html", templateLoader());
590 				return skeleton.requireSelector("main");
591 			} catch(Exception e) {
592 				auto document = new Document("<html><body><p>generic.html trouble: <span id=\"ghe\"></span></p> <main></main></body></html>");
593 				document.requireSelector("#ghe").textContent = e.msg;
594 				return document.requireSelector("main");
595 			}
596 		}
597 
598 		static struct Meta {
599 			typeof(null) at;
600 			string templateName;
601 			string skeletonName;
602 			string[string] meta;
603 			Form function(WebPresenterWithTemplateSupport presenter) automaticForm;
604 			alias at this;
605 		}
606 		template methodMeta(alias method) {
607 			static Meta helper() {
608 				Meta ret;
609 
610 				// ret.at = typeof(super).methodMeta!method;
611 
612 				foreach(attr; __traits(getAttributes, method))
613 					static if(is(typeof(attr) == Template))
614 						ret.templateName = attr.name;
615 					else static if(is(typeof(attr) == Skeleton))
616 						ret.skeletonName = attr.name;
617 					else static if(is(typeof(attr) == .meta))
618 						ret.meta[attr.name] = attr.value;
619 
620 				ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) {
621 					return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null);
622 				};
623 
624 				return ret;
625 			}
626 			enum methodMeta = helper();
627 		}
628 
629 		/// You can override this
630 		void addContext(Cgi cgi, var ctx) {}
631 
632 		/++
633 			You can override this. The default is "templates/". Your returned string must end with '/'.
634 			(in future versions it will probably allow a null return too, but right now it must be a /).
635 
636 			History:
637 				Added December 6, 2023 (dub v11.3)
638 		+/
639 		TemplateLoader templateLoader() {
640 			return null;
641 		}
642 
643 		void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) {
644 			addContext(cgi, ret.context);
645 			auto skeleton = renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName, templateLoader());
646 			cgi.setResponseContentType("text/html; charset=utf8");
647 			cgi.gzipResponse = true;
648 			cgi.write(skeleton.toString(), true);
649 		}
650 
651 		void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) {
652 			if(meta.templateName.length) {
653 				var sobj = var.emptyObject;
654 
655 				var obj = var.emptyObject;
656 
657 				obj.data = ret;
658 
659 				/+
660 				sobj.meta = var.emptyObject;
661 				foreach(k,v; meta.meta)
662 					sobj.meta[k] = v;
663 				+/
664 
665 				obj.meta = var.emptyObject;
666 				foreach(k,v; meta.meta)
667 					obj.meta[k] = v;
668 
669 				obj.meta.currentPath = cgi.pathInfo;
670 				obj.meta.automaticForm = { return meta.automaticForm(this).toString; };
671 
672 				presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta);
673 			} else
674 				super.presentSuccessfulReturnAsHtml(cgi, ret, meta);
675 		}
676 	}
677 }
678 
679 /++
680 	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.
681 
682 	Parameters:
683 		urlPrefix = the url prefix to trigger this handler, relative to the current dispatcher base
684 		directory = the directory, under the template directory, to find the template files
685 		skeleton = the name of the skeleton file inside the template directory
686 		extension = the file extension to add to the url name to get the template name
687 
688 	To get the filename of the template from the url, it will:
689 
690 	1) Strip the url prefixes off to get just the filename
691 
692 	2) Concatenate the directory with the template directory
693 
694 	3) Add the extension to the givenname
695 
696 	$(PITFALL
697 		The `templateDirectory` parameter may be removed or changed in the near future.
698 	)
699 
700 	History:
701 		Added July 28, 2021 (documented dub v11.0)
702 +/
703 auto serveTemplateDirectory()(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html", string templateDirectory = "templates/") {
704 	import arsd.cgi;
705 	import std.file;
706 
707 	assert(urlPrefix[0] == '/');
708 	assert(urlPrefix[$-1] == '/');
709 
710 	assert(templateDirectory[$-1] == '/');
711 
712 	static struct DispatcherDetails {
713 		string directory;
714 		string skeleton;
715 		string extension;
716 		string templateDirectory;
717 	}
718 
719 	if(directory is null)
720 		directory = urlPrefix[1 .. $];
721 
722 	if(directory.length == 0)
723 		directory = "./";
724 
725 	assert(directory[$-1] == '/');
726 
727 	static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) {
728 		auto file = cgi.pathInfo[urlPrefix.length .. $];
729 		if(file.indexOf("/") != -1 || file.indexOf("\\") != -1)
730 			return false;
731 
732 		auto fn = details.templateDirectory ~ details.directory ~ file ~ details.extension;
733 		if(std.file.exists(fn)) {
734 			cgi.setCache(true);
735 			auto doc = renderTemplate(fn[details.templateDirectory.length.. $], var.emptyObject, var.emptyObject, details.skeleton, TemplateLoader.forDirectory(details.templateDirectory));
736 			cgi.gzipResponse = true;
737 			cgi.write(doc.toString, true);
738 			return true;
739 		} else {
740 			return false;
741 		}
742 	}
743 
744 	return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension, templateDirectory));
745 }