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