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 Document renderTemplate(string templateName, var context = var.emptyObject, var skeletonContext = var.emptyObject, string skeletonName = null) {
148 	import std.file;
149 	import arsd.cgi;
150 
151 	try {
152 		addDefaultFunctions(context);
153 		addDefaultFunctions(skeletonContext);
154 
155 		if(skeletonName.length == 0)
156 			skeletonName = "skeleton.html";
157 
158 		auto skeleton = new Document(readText("templates/" ~ skeletonName), true, true);
159 		auto document = new Document();
160 		document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom
161 		document.parse("<root>" ~ readText("templates/" ~ templateName) ~ "</root>", true, true);
162 
163 		expandTemplate(skeleton.root, skeletonContext);
164 
165 		foreach(nav; skeleton.querySelectorAll("nav[data-relative-to]")) {
166 			auto r = nav.getAttribute("data-relative-to");
167 			foreach(a; nav.querySelectorAll("a")) {
168 				a.attrs.href = Uri(a.attrs.href).basedOn(Uri(r));// ~ a.attrs.href;
169 			}
170 		}
171 
172 		expandTemplate(document.root, context);
173 
174 		// also do other unique elements and move them over.
175 		// and try partials.
176 
177 		auto templateMain = document.requireSelector(":root > main");
178 		if(templateMain.hasAttribute("body-class")) {
179 			skeleton.requireSelector("body").addClass(templateMain.getAttribute("body-class"));
180 			templateMain.removeAttribute("body-class");
181 		}
182 
183 		skeleton.requireSelector("main").replaceWith(templateMain.removeFromTree);
184 		if(auto title = document.querySelector(":root > title"))
185 			skeleton.requireSelector(":root > head > title").innerHTML = title.innerHTML;
186 
187 		debug
188 		skeleton.root.prependChild(new HtmlComment(null, templateName ~ " inside skeleton.html"));
189 
190 		return skeleton;
191 	} catch(Exception e) {
192 		throw new TemplateException(templateName, context, e);
193 		//throw e;
194 	}
195 }
196 
197 // I don't particularly like this
198 void expandTemplate(Element root, var context) {
199 	import std.string;
200 
201 	string replaceThingInString(string v) {
202 		auto idx = v.indexOf("<%=");
203 		if(idx == -1)
204 			return v;
205 		auto n = v[0 .. idx];
206 		auto r = v[idx + "<%=".length .. $];
207 
208 		auto end = r.indexOf("%>");
209 		if(end == -1)
210 			throw new Exception("unclosed asp code in attribute");
211 		auto code = r[0 .. end];
212 		r = r[end + "%>".length .. $];
213 
214 		import arsd.script;
215 		auto res = interpret(code, context).get!string;
216 
217 		return n ~ res ~ replaceThingInString(r);
218 	}
219 
220 	foreach(k, v; root.attributes) {
221 		if(k == "onrender") {
222 			continue;
223 		}
224 
225 		v = replaceThingInString(v);
226 
227 		root.setAttribute(k, v);
228 	}
229 
230 	bool lastBoolResult;
231 
232 	foreach(ele; root.children) {
233 		if(ele.tagName == "if-true") {
234 			auto fragment = new DocumentFragment(null);
235 			import arsd.script;
236 			auto got = interpret(ele.attrs.cond, context).opCast!bool;
237 			if(got) {
238 				ele.tagName = "root";
239 				expandTemplate(ele, context);
240 				fragment.stealChildren(ele);
241 			}
242 			lastBoolResult = got;
243 			ele.replaceWith(fragment);
244 		} else if(ele.tagName == "or-else") {
245 			auto fragment = new DocumentFragment(null);
246 			if(!lastBoolResult) {
247 				ele.tagName = "root";
248 				expandTemplate(ele, context);
249 				fragment.stealChildren(ele);
250 			}
251 			ele.replaceWith(fragment);
252 		} else if(ele.tagName == "for-each") {
253 			auto fragment = new DocumentFragment(null);
254 			var nc = var.emptyObject(context);
255 			lastBoolResult = false;
256 			auto got = interpret(ele.attrs.over, context);
257 			foreach(k, item; got) {
258 				lastBoolResult = true;
259 				nc[ele.attrs.as] = item;
260 				if(ele.attrs.index.length)
261 					nc[ele.attrs.index] = k;
262 				auto clone = ele.cloneNode(true);
263 				clone.tagName = "root"; // it certainly isn't a for-each anymore!
264 				expandTemplate(clone, nc);
265 
266 				fragment.stealChildren(clone);
267 			}
268 			ele.replaceWith(fragment);
269 		} else if(ele.tagName == "render-template") {
270 			import std.file;
271 			auto templateName = ele.getAttribute("file");
272 			auto document = new Document();
273 			document.parseSawAspCode = (string) => true; // enable adding <% %> to the dom
274 			document.parse("<root>" ~ readText("templates/" ~ templateName) ~ "</root>", true, true);
275 
276 			var obj = var.emptyObject;
277 			obj.prototype = context;
278 
279 			// FIXME: there might be other data you pass from the parent...
280 			if(auto data = ele.getAttribute("data")) {
281 				obj["data"] = var.fromJson(data);
282 			}
283 
284 			expandTemplate(document.root, obj);
285 
286 			auto fragment = new DocumentFragment(null);
287 
288 			debug fragment.appendChild(new HtmlComment(null, templateName));
289 			fragment.stealChildren(document.root);
290 			debug fragment.appendChild(new HtmlComment(null, "end " ~ templateName));
291 
292 			ele.replaceWith(fragment);
293 		} else if(ele.tagName == "hidden-form-data") {
294 			auto from = interpret(ele.attrs.from, context);
295 			auto name = ele.attrs.name;
296 
297 			auto form = new Form(null);
298 
299 			populateForm(form, from, name);
300 
301 			auto fragment = new DocumentFragment(null);
302 			fragment.stealChildren(form);
303 
304 			ele.replaceWith(fragment);
305 		} else if(auto asp = cast(AspCode) ele) {
306 			auto code = asp.source[1 .. $-1];
307 			auto fragment = new DocumentFragment(null);
308 			if(code[0] == '=') {
309 				import arsd.script;
310 				if(code.length > 5 && code[1 .. 5] == "HTML") {
311 					auto got = interpret(code[5 .. $], context);
312 					if(auto native = got.getWno!Element)
313 						fragment.appendChild(native);
314 					else
315 						fragment.innerHTML = got.get!string;
316 				} else {
317 					auto got = interpret(code[1 .. $], context).get!string;
318 					fragment.innerText = got;
319 				}
320 			}
321 			asp.replaceWith(fragment);
322 		} else if(ele.tagName == "script") {
323 			auto source = ele.innerHTML;
324 			string newCode;
325 			check_more:
326 			auto idx = source.indexOf("<%=");
327 			if(idx != -1) {
328 				newCode ~= source[0 .. idx];
329 				auto remaining = source[idx + 3 .. $];
330 				idx = remaining.indexOf("%>");
331 				if(idx == -1)
332 					throw new Exception("unclosed asp code in script");
333 				auto code = remaining[0 .. idx];
334 
335 				auto data = interpret(code, context);
336 				newCode ~= data.toJson();
337 
338 				source = remaining[idx + 2 .. $];
339 				goto check_more;
340 			}
341 
342 			if(newCode is null)
343 				{} // nothing needed
344 			else {
345 				newCode ~= source;
346 				ele.innerRawSource = newCode;
347 			}
348 		} else {
349 			expandTemplate(ele, context);
350 		}
351 	}
352 
353 	if(root.hasAttribute("onrender")) {
354 		var nc = var.emptyObject(context);
355 		nc["this"] = wrapNativeObject(root);
356 		nc["this"]["populateFrom"] = delegate var(var this_, var[] args) {
357 			auto form = cast(Form) root;
358 			if(form is null) return this_;
359 			foreach(k, v; args[0]) {
360 				populateForm(form, v, k.get!string);
361 			}
362 			return this_;
363 		};
364 		interpret(root.getAttribute("onrender"), nc);
365 
366 		root.removeAttribute("onrender");
367 	}
368 }
369 
370 void populateForm(Form form, var obj, string name) {
371 	import std.string;
372 
373 	if(obj.payloadType == var.Type.Object) {
374 		form.setValue(name, "");
375 		foreach(k, v; obj) {
376 			auto fn = name.replace("%", k.get!string);
377 			// should I unify structs and assoctiavite arrays?
378 			populateForm(form, v, fn ~ "["~k.get!string~"]");
379 			//populateForm(form, v, fn ~"."~k.get!string);
380 		}
381 	} else {
382 		//import std.stdio; writeln("SET ", name, " ", obj, " ", obj.payloadType);
383 		form.setValue(name, obj.get!string);
384 	}
385 
386 }
387 
388 /++
389 	Replaces `things[0]` with `things[1]` in `what` all at once.
390 	Returns the new string.
391 
392 	History:
393 		Added February 12, 2022. I might move it later.
394 +/
395 string multiReplace(string what, string[] things...) {
396 	import std.string; // FIXME: indexOf not actually ideal but meh
397 	if(things.length == 0)
398 		return what;
399 
400 	assert(things.length % 2 == 0);
401 
402 	string n;
403 
404 	while(what.length) {
405 		int nextIndex = cast(int) what.length;
406 		int nextThing = -1;
407 
408 		foreach(i, thing; things) {
409 			if(i & 1)
410 				continue;
411 
412 			auto idx = what.indexOf(thing);
413 			if(idx != -1 && idx < nextIndex) {
414 				nextIndex = cast(int) idx;
415 				nextThing = cast(int) i;
416 			}
417 		}
418 
419 		if(nextThing == -1) {
420 			n ~= what;
421 			what = null;
422 		} else {
423 			n ~= what[0 .. nextIndex];
424 			what = what[nextIndex + things[nextThing].length .. $];
425 			n ~= things[nextThing + 1];
426 			continue;
427 		}
428 	}
429 
430 	return n;
431 }
432 
433 immutable daysOfWeekFullNames = [
434 	"Sunday",
435 	"Monday",
436 	"Tuesday",
437 	"Wednesday",
438 	"Thursday",
439 	"Friday",
440 	"Saturday"
441 ];
442 
443 /++
444 	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.
445 
446 	Inside the template, the value returned by the function will be available in the context as the variable `data`.
447 +/
448 struct Template {
449 	string name;
450 }
451 /++
452 	UDA to put on a method when using [WebPresenterWithTemplateSupport]. Overrides the default template skeleton file name.
453 +/
454 struct Skeleton {
455 	string name;
456 }
457 
458 /++
459 	UDA to attach runtime metadata to a function. Will be available in the template.
460 
461 	History:
462 		Added July 12, 2021
463 +/
464 struct meta {
465 	string name;
466 	string value;
467 }
468 
469 /++
470 	Can be used as a return value from one of your own methods when rendering websites with [WebPresenterWithTemplateSupport].
471 +/
472 struct RenderTemplate {
473 	string name;
474 	var context = var.emptyObject;
475 	var skeletonContext = var.emptyObject;
476 	string skeletonName;
477 }
478 
479 
480 /++
481 	Make a class that inherits from this with your further customizations, or minimally:
482 	---
483 	class MyPresenter : WebPresenterWithTemplateSupport!MyPresenter { }
484 	---
485 +/
486 template WebPresenterWithTemplateSupport(CTRP) {
487 	import arsd.cgi;
488 	class WebPresenterWithTemplateSupport : WebPresenter!(CTRP) {
489 		override Element htmlContainer() {
490 			auto skeleton = renderTemplate("generic.html");
491 			return skeleton.requireSelector("main");
492 		}
493 
494 		static struct Meta {
495 			typeof(null) at;
496 			string templateName;
497 			string skeletonName;
498 			string[string] meta;
499 			Form function(WebPresenterWithTemplateSupport presenter) automaticForm;
500 			alias at this;
501 		}
502 		template methodMeta(alias method) {
503 			static Meta helper() {
504 				Meta ret;
505 
506 				// ret.at = typeof(super).methodMeta!method;
507 
508 				foreach(attr; __traits(getAttributes, method))
509 					static if(is(typeof(attr) == Template))
510 						ret.templateName = attr.name;
511 					else static if(is(typeof(attr) == Skeleton))
512 						ret.skeletonName = attr.name;
513 					else static if(is(typeof(attr) == .meta))
514 						ret.meta[attr.name] = attr.value;
515 
516 				ret.automaticForm = function Form(WebPresenterWithTemplateSupport presenter) {
517 					return presenter.createAutomaticFormForFunction!(method, typeof(&method))(null);
518 				};
519 
520 				return ret;
521 			}
522 			enum methodMeta = helper();
523 		}
524 
525 		/// You can override this
526 		void addContext(Cgi cgi, var ctx) {}
527 
528 		void presentSuccessfulReturnAsHtml(T : RenderTemplate)(Cgi cgi, T ret, Meta meta) {
529 			addContext(cgi, ret.context);
530 			auto skeleton = renderTemplate(ret.name, ret.context, ret.skeletonContext, ret.skeletonName);
531 			cgi.setResponseContentType("text/html; charset=utf8");
532 			cgi.gzipResponse = true;
533 			cgi.write(skeleton.toString(), true);
534 		}
535 
536 		void presentSuccessfulReturnAsHtml(T)(Cgi cgi, T ret, Meta meta) {
537 			if(meta.templateName.length) {
538 				var sobj = var.emptyObject;
539 
540 				var obj = var.emptyObject;
541 
542 				obj.data = ret;
543 
544 				/+
545 				sobj.meta = var.emptyObject;
546 				foreach(k,v; meta.meta)
547 					sobj.meta[k] = v;
548 				+/
549 
550 				obj.meta = var.emptyObject;
551 				foreach(k,v; meta.meta)
552 					obj.meta[k] = v;
553 
554 				obj.meta.currentPath = cgi.pathInfo;
555 				obj.meta.automaticForm = { return meta.automaticForm(this).toString; };
556 
557 				presentSuccessfulReturnAsHtml(cgi, RenderTemplate(meta.templateName, obj, sobj, meta.skeletonName), meta);
558 			} else
559 				super.presentSuccessfulReturnAsHtml(cgi, ret, meta);
560 		}
561 	}
562 }
563 
564 /++
565 	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.
566 
567 	History:
568 		Added July 28, 2021 (documented dub v11.0)
569 +/
570 auto serveTemplateDirectory()(string urlPrefix, string directory = null, string skeleton = null, string extension = ".html") {
571 	import arsd.cgi;
572 	import std.file;
573 
574 	assert(urlPrefix[0] == '/');
575 	assert(urlPrefix[$-1] == '/');
576 
577 	static struct DispatcherDetails {
578 		string directory;
579 		string skeleton;
580 		string extension;
581 	}
582 
583 	if(directory is null)
584 		directory = urlPrefix[1 .. $];
585 
586 	assert(directory[$-1] == '/');
587 
588 	static bool internalHandler(string urlPrefix, Cgi cgi, Object presenter, DispatcherDetails details) {
589 		auto file = cgi.pathInfo[urlPrefix.length .. $];
590 		if(file.indexOf("/") != -1 || file.indexOf("\\") != -1)
591 			return false;
592 
593 		auto fn = "templates/" ~ details.directory ~ file ~ details.extension;
594 		if(std.file.exists(fn)) {
595 			cgi.setCache(true);
596 			auto doc = renderTemplate(fn["templates/".length.. $]);
597 			cgi.gzipResponse = true;
598 			cgi.write(doc.toString, true);
599 			return true;
600 		} else {
601 			return false;
602 		}
603 	}
604 
605 	return DispatcherDefinition!(internalHandler, DispatcherDetails)(urlPrefix, false, DispatcherDetails(directory, skeleton, extension));
606 }