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