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 `<`, "<", 14 `>`, ">", 15 `&`, "&", 16 `"`, """, 17 `'`, "'", 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>-> Sanitized</div>" 266 267 ); 268 269 // Sanitized user input in attributes 270 assert( 271 272 elem!"input"(["value": `"XSS!"`]) 273 == `<input value=""XSS!"" />` 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>-> 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 }