1 module elemi;
2 
3 import std.conv;
4 import std.string;
5 import std.algorithm;
6 
7 /// Escape HTML elements.
8 ///
9 /// Package level: input sanitization is done automatically by the library.
10 package string escapeHTML(const string text) {
11 
12     return text.substitute!(
13         `<`, "&lt;",
14         `>`, "&gt;",
15         `&`, "&amp;",
16         `"`, "&quot;",
17         `'`, "&#39;",
18     ).to!string;
19 
20 }
21 
22 /// Serialize attributes
23 package string serializeAttributes(string[string] attributes) {
24 
25     // Generate attribute text
26     string attrHTML;
27     foreach (key, value; attributes) {
28 
29         attrHTML ~= format!` %s="%s"`(key, value.escapeHTML);
30 
31     }
32 
33     return attrHTML;
34 
35 }
36 
37 /// Process the given content, sanitizing user input and passing in already created elements.
38 package string processContent(T...)(T content) {
39 
40     string contentText;
41     static foreach (i, Type; T) {
42 
43         // Given a string
44         static if (is(Type == string) || is(Type == wstring) || is(Type == dstring)) {
45 
46             /// Escape it and add
47             contentText ~= content[i].escapeHTML;
48 
49         }
50 
51         // Given an element, just add it
52         else contentText ~= content[i];
53     }
54 
55     return contentText;
56 
57 }
58 
59 /// Represents a HTML element.
60 ///
61 /// Use [elem] to generate.
62 struct Element {
63 
64     // Commonly used elements
65 
66     /// Doctype info for HTML.
67     enum HTMLDoctype = "<!DOCTYPE html>";
68 
69     /// A common head element for adjusting the viewport to mobile devices.
70     enum MobileViewport = elem!("meta", q{
71         name="viewport"
72         content="width=device-width, initial-scale=1"
73     });
74 
75     package {
76 
77         /// HTML of the element.
78         string html;
79 
80         /// Added at the end
81         string postHTML;
82 
83     }
84 
85     package this(const string name, const string attributes = null, const string content = null) {
86 
87         auto attrHTML = attributes.dup;
88 
89         // Asserts on attributes
90         debug if (attributes.length) {
91 
92             assert(attributes[0] == ' ', "The first character of attributes isn't a space");
93             assert(attributes[1] != ' ', "The second character of attributes cannot be a space");
94 
95         }
96 
97         // Create the ending tag
98         switch (name) {
99 
100             // Empty elements
101             case "area", "base", "br", "col", "embed", "hr", "img", "input":
102             case "keygen", "link", "meta", "param", "source", "track", "wbr":
103 
104                 assert(content.length == 0, "Tag %s cannot have children, its content must be empty.".format(name));
105 
106                 // Instead of a tag end, add a slash at the end of the beginning tag
107                 // Also add a space if there are any attributes
108                 attrHTML ~= (attrHTML ? " " : "") ~ "/";
109 
110                 break;
111 
112             // Containers
113             default:
114 
115                 // Add the end tag
116                 postHTML = name.format!"</%s>";
117 
118         }
119 
120         html = format!"<%s%s>%s"(name, attrHTML, content);
121 
122     }
123 
124     string toString() const {
125 
126         return html ~ postHTML;
127 
128     }
129 
130     /// Create a new element as a child
131     Element add(string name, string[string] attributes = null, T...)(T content) {
132 
133         html ~= elem!(name, attributes)(content);
134         return this;
135 
136     }
137 
138     /// Ditto
139     Element add(string name, string attrHTML = null, T...)(string[string] attributes, T content) {
140 
141         html ~= elem!(name, attrHTML)(attributes, content);
142         return this;
143 
144     }
145 
146     /// Ditto
147     Element add(string name, string attrHTML, T...)(T content) {
148 
149         html ~= elem!(name, attrHTML)(null, content);
150         return this;
151 
152     }
153 
154     /// Add an element as a child
155     Element add(Element element) {
156 
157         html ~= element;
158         return this;
159 
160     }
161 
162     /// Add text as a child.
163     ///
164     /// The text will automatically be sanitized.
165     Element add(string text) {
166 
167         html ~= text.escapeHTML;
168         return this;
169 
170     }
171 
172     // Yes. This is legal.
173     alias toString this;
174 
175 }
176 
177 /// Create a HTML element.
178 ///
179 /// Params:
180 ///     name = Name of the element.
181 ///     attrHTML = Unsanitized attributes to insert.
182 ///     attributes = Attributes for the element.
183 ///     children = Children and text of the element.
184 /// Returns: a Element type, implictly castable to string.
185 Element elem(string name, string[string] attributes = null, T...)(T content)
186 if (!T.length || !is(T[0] == string[string])) {
187 
188     // Ensure attribute HTML is generated compile-time.
189     enum attrHTML = attributes.serializeAttributes;
190 
191     return Element(name, attrHTML, content.processContent);
192 
193 }
194 
195 /// Ditto
196 Element elem(string name, string attrHTML = null, T...)(string[string] attributes, T content) {
197 
198     import std.stdio : writeln;
199 
200     enum attrInput = attrHTML.splitter("\n")
201         .map!q{a.strip}
202         .filter!q{a.length}
203         .join(" ");
204 
205     enum prefix = attrHTML.length ? " " : "";
206 
207     return Element(
208         name,
209         prefix ~ attrInput ~ attributes.serializeAttributes,
210         content.processContent
211     );
212 
213 }
214 
215 /// Ditto
216 Element elem(string name, string attrHTML, T...)(T content)
217 if (!T.length || !is(T[0] == typeof(null)) ) {
218 
219     return elem!(name, attrHTML)(null, content);
220 
221 }
222 
223 ///
224 unittest {
225 
226     import std.stdio : writeln;
227 
228     // Compile-time empty type detection
229     assert(elem!"input" == "<input/>");
230     assert(elem!"hr" == "<hr/>");
231     assert(elem!"p" == "<p></p>");
232 
233     // Content
234     assert(elem!"p"("Hello, World!") == "<p>Hello, World!</p>");
235 
236     // Compile-time attributes — variant A
237     assert(
238 
239         elem!("a", [ "href": "about:blank", "title": "Destroy this page" ])("Hello, World!")
240 
241         == `<a href="about:blank" title="Destroy this page">Hello, World!</a>`
242 
243     );
244 
245     // Compile-time attributes — variant B
246     assert(
247 
248         elem!("a", q{
249             href="about:blank"
250             title="Destroy this page" })(
251             "Hello, World!"
252         )
253         == `<a href="about:blank" title="Destroy this page">Hello, World!</a>`
254 
255     );
256 
257     // Nesting and input sanitization
258     assert(
259 
260         elem!"div"(
261             elem!"p"("Hello, World!"),
262             "-> Sanitized"
263         )
264 
265         == "<div><p>Hello, World!</p>-&gt; Sanitized</div>"
266 
267     );
268 
269     // Sanitized user input in attributes
270     assert(
271 
272         elem!"input"(["value": `"XSS!"`])
273         == `<input value="&quot;XSS!&quot;" />`
274 
275     );
276 
277     // Alternative method of nesting
278     assert(
279 
280         elem!("div", q{ style="background:#500" })
281             .add!"p"("Hello, World!")
282             .add("-> Sanitized")
283 
284         == `<div style="background:#500"><p>Hello, World!</p>-&gt; Sanitized</div>`
285 
286     );
287 
288 }
289 
290 /// A general example page
291 unittest {
292 
293     import std.stdio : writeln;
294     import std.base64 : Base64;
295 
296     enum page = Element.HTMLDoctype ~ elem!"html"(
297 
298         elem!"head"(
299 
300             elem!("title")("An example document"),
301 
302             // Metadata
303             Element.MobileViewport,
304 
305             elem!("style")(`
306 
307                 html, body {
308                     height: 100%;
309                     font-family: sans-serif;
310                     padding: 0;
311                     margin: 0;
312                 }
313                 .header {
314                     background: #f7a;
315                     font-size: 1.5em;
316                     margin: 0;
317                     padding: 5px;
318                 }
319                 .article {
320                     padding-left: 2em;
321                 }
322 
323             `.split("\n").map!"a.strip".filter!"a.length".join),
324 
325         ),
326 
327         elem!"body"(
328 
329             elem!("header", q{ class="header" })(
330 
331                 elem!"h1"("Example website")
332 
333             ),
334 
335             elem!"h1"("Welcome to my website!"),
336             elem!"p"("Hello there,",
337                 elem!"br", "may you want to read some of my articles?"),
338 
339             elem!("div", q{ class="article" })(
340                 elem!"h2"("Stuff"),
341                 elem!"p"("Description")
342             )
343 
344         )
345 
346     );
347 
348     enum target = cast(string) Base64.decode([
349         "PCFET0NUWVBFIGh0bWw+PGh0bWw+PGhlYWQ+PHRpdGxlPkFuIGV4YW1wbGUgZG9jdW",
350         "1lbnQ8L3RpdGxlPjxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1k",
351         "ZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MSIgLz48c3R5bGU+aHRtbCwgYm9keS",
352         "B7aGVpZ2h0OiAxMDAlO2ZvbnQtZmFtaWx5OiBzYW5zLXNlcmlmO3BhZGRpbmc6IDA7",
353         "bWFyZ2luOiAwO30uaGVhZGVyIHtiYWNrZ3JvdW5kOiAjZjdhO2ZvbnQtc2l6ZTogMS",
354         "41ZW07bWFyZ2luOiAwO3BhZGRpbmc6IDVweDt9LmFydGljbGUge3BhZGRpbmctbGVm",
355         "dDogMmVtO308L3N0eWxlPjwvaGVhZD48Ym9keT48aGVhZGVyIGNsYXNzPSJoZWFkZX",
356         "IiPjxoMT5FeGFtcGxlIHdlYnNpdGU8L2gxPjwvaGVhZGVyPjxoMT5XZWxjb21lIHRv",
357         "IG15IHdlYnNpdGUhPC9oMT48cD5IZWxsbyB0aGVyZSw8YnIvPm1heSB5b3Ugd2FudC",
358         "B0byByZWFkIHNvbWUgb2YgbXkgYXJ0aWNsZXM/PC9wPjxkaXYgY2xhc3M9ImFydGlj",
359         "bGUiPjxoMj5TdHVmZjwvaDI+PHA+RGVzY3JpcHRpb248L3A+PC9kaXY+PC9ib2R5Pj",
360         "wvaHRtbD4="
361     ].join);
362 
363     assert(page == target);
364 
365 }
366 
367 // README example
368 unittest {
369 
370     import elemi : elem, Element;
371 
372     auto document = Element.HTMLDoctype ~ elem!"html"(
373 
374         elem!"head"(
375             elem!"title"("Hello, World!"),
376             Element.MobileViewport,
377         ),
378 
379         elem!"body"(
380 
381             // All input is sanitized.
382             "<Welcome to my website!>"
383 
384         ),
385 
386     );
387 
388 }