From 193e6b9f015e5aa4c562d6bb9d64341794184cca Mon Sep 17 00:00:00 2001 From: Artha Date: Tue, 30 Nov 2021 16:34:57 +0100 Subject: [PATCH 1/4] Refactor elemi into separate modules, add attr! --- readme.md | 14 ++- source/elemi.d | 117 ++++++++++++++++++++++-- source/elemi/attribute.d | 67 ++++++++++++++ source/elemi/element.d | 187 +++++++++++++++++++++++++++++++++++++++ source/elemi/html.d | 60 +++++++++++++ source/elemi/internal.d | 60 +++++++++++++ source/elemi/xml.d | 71 +++++++++++++++ 7 files changed, 569 insertions(+), 7 deletions(-) create mode 100644 source/elemi/attribute.d create mode 100644 source/elemi/element.d create mode 100644 source/elemi/html.d create mode 100644 source/elemi/internal.d create mode 100644 source/elemi/xml.d diff --git a/readme.md b/readme.md index 2f37c62..a1421ad 100644 --- a/readme.md +++ b/readme.md @@ -17,9 +17,19 @@ auto document = Element.HTMLDoctype ~ elem!"html"( ), elem!"body"( + attr("class") = ["home", "logged-in"], - // All input is sanitized. - "" + elem!"main"( + + elem!"img"( + attr("src") = "/logo.png", + attr("alt") = "Website logo" + ), + + // All input is sanitized. + "" + + ) ), diff --git a/source/elemi.d b/source/elemi.d index d4aa423..cefbe11 100644 --- a/source/elemi.d +++ b/source/elemi.d @@ -1,12 +1,15 @@ - -module elemi; +module elemi_old; import std.conv; import std.string; import std.algorithm; + pure @safe: +version (none): + + /// Escape HTML elements. /// /// Package level: input sanitization is done automatically by the library. @@ -48,6 +51,23 @@ package string serializeAttributes(string[string] attributes) { } +/// Process the given content, filtering it to attributes. +package string processAttributes(T...)(T content) { + + string attrText; + static foreach (i, Type; T) { + + static if (is(Type : Attribute)) { + + attrText ~= " " ~ content; + + } + + } + + return attrText; + +} /// Process the given content, sanitizing user input and passing in already created elements. package string processContent(T...)(T content) @@ -65,8 +85,11 @@ package string processContent(T...)(T content, bool escape) { string contentText; static foreach (i, Type; T) { + // Given an attribute, ignore it + static if (is(Type == Attribute)) { } + // Given a string - static if (is(Type == string) || is(Type == wstring) || is(Type == dstring)) { + else static if (is(Type == string) || is(Type == wstring) || is(Type == dstring)) { // Escape it and add contentText ~= escape @@ -130,6 +153,9 @@ struct Element { /// is placed within the tag itself. bool directive; + /// Attribute string of the element. + string attrs; + /// HTML of the element. string html; @@ -250,6 +276,7 @@ struct Element { /// Add a child Element add(T...)(T content) { + html ~= content.processContent(!directive); return this; @@ -299,6 +326,8 @@ struct Element { } + alias opOpAssign(string op = "~") = add; + // Yes. This is legal. alias toString this; @@ -408,6 +437,13 @@ unittest { ); // Sanitized user input in attributes + assert( + elem!"input"( + attr("type") = "text", + attr("value") = `"XSS!"` + ) == `` + ); + assert( elem!"input"(["type": "text", "value": `"XSS!"`]) @@ -448,6 +484,17 @@ unittest { // Significant whitespace assert(elem!"span"(" Foo ") == " Foo "); + // Also with tilde + auto myElem = elem!"div"; + myElem ~= elem!"span"("Sample"); + myElem ~= " "; + myElem ~= elem!"span"("Text"); + myElem ~= attr("class") = "test"; + + assert( + myElem == `
Sample Text
` + ); + } /// A general example page @@ -527,6 +574,56 @@ unittest { } +/// Represents an attribute. +struct Attribute { + + /// Name of the attribute. + string name; + + /// Value assigned to the attribute. + string value; + + /// Assign a new value + void opAssign(string newValue) { + + value = newValue; + + } + + void opAssign(string[] newValues) { + + value = newValues.join; + + } + + string toString() { + + return format!q{%s="%s"}(name, value.escapeHTML); + + } + +} + +/// Create an attribute +Attribute attr(string name) { + return Attribute(name); +} + +/// ditto +Attribute attr(string name)() { + return Attribute(name); +} + + +unittest { + + assert(elem!"div"( + attr("id") = "name", + attr("class") = ["hello", "world"], + ) == `
`); + +} + // README example unittest { @@ -541,9 +638,19 @@ unittest { ), elem!"body"( + attr("class") = ["home", "logged-in"], + + elem!"main"( - // All input is sanitized. - "" + elem!"img"( + attr("src") = "/logo.png", + attr("alt") = "Website logo" + ), + + // All input is sanitized. + "" + + ) ), diff --git a/source/elemi/attribute.d b/source/elemi/attribute.d new file mode 100644 index 0000000..214d35d --- /dev/null +++ b/source/elemi/attribute.d @@ -0,0 +1,67 @@ +module elemi.attribute; + +import std.string; + +import elemi.internal; + + +pure @safe: + + +/// Represents an attribute. +struct Attribute { + + pure: + + /// Name of the attribute. + string name; + + /// Value assigned to the attribute. + string value; + + /// Assign a new value + void opAssign(string newValue) { + + value = newValue; + + } + + void opAssign(string[] newValues) { + + value = newValues.join; + + } + + string toString() { + + return format!q{%s="%s"}(name, value.escapeHTML); + + } + + alias toString this; + +} + +Attribute attr(string name) { + + return Attribute(name); + +} + +Attribute attr(string name)() { + + return Attribute(name); + +} + +Attribute attr(string name, string value) { + + return Attribute(name, value); + +} + +Attribute attr(string name)(string value) { + + return Attribute(name, value); + +} diff --git a/source/elemi/element.d b/source/elemi/element.d new file mode 100644 index 0000000..4bdee34 --- /dev/null +++ b/source/elemi/element.d @@ -0,0 +1,187 @@ +module elemi.element; + +import std.string; + +import elemi.xml; +import elemi.internal; + + +pure @safe: + + +/// A collection to hold multiple elements next to each other without a wrapper element. +enum elems = Element(); + +/// Represents a HTML element. +/// +/// Use [elem] to generate. +struct Element { + + pure: + + // Commonly used elements + enum { + + /// Doctype info for HTML. + HTMLDoctype = elemX!"!DOCTYPE"("html"), + + /// XML declaration element. Uses version 1.1. + XMLDeclaration = elemX!"?xml"( + attr!"version" = "1.1", + attr!"encoding" = "UTF-8", + ), + + /// Enables UTF-8 encoding for the document + EncodingUTF8 = elemX!"meta"( + attr!"charset" = "utf-8", + ), + + /// A common head element for adjusting the viewport to mobile devices. + MobileViewport = elemX!"meta"( + attr!"name" = "viewport", + attr!"content" = "width=device-width, initial-scale=1" + ), + + } + + package { + + string startTag; + string attributes; + string trail; + string content; + string endTag; + + } + + /// Create the tag. + static package Element make(string tagName)() + in(tagName.length != 0, "Tag name cannot be empty") + do { + + Element that; + + // Self-closing tag + static if (tagName[$-1] == '/') { + + // Enforce CTFE + enum startTag = format!"<%s"(tagName[0..$-1].stripRight); + + that.startTag = startTag; + that.trail = "/>"; + + } + + // XML tag + else static if (tagName[0] == '?') { + + that.startTag = format!"<%s"(tagName); + that.trail = " "; + that.endTag = "?>"; + + } + + // Declaration + else static if (tagName[0] == '!') { + + that.startTag = format!"<%s"(tagName); + that.trail = " "; + that.endTag = ">"; + + } + + else { + + that.startTag = format!"<%s"(tagName); + that.trail = ">"; + that.endTag = format!""(tagName); + + } + + return that; + + } + + void opOpAssign(string op = "~", Ts...)(Ts args) { + + // Check each argument + static foreach (i, Type; Ts) { + + addItem(args[i]); + + } + + } + + pragma(inline, true) + private void addItem(Type)(Type item) { + + import std.range; + import std.traits; + + // Attribute + static if (is(Type : Attribute)) { + + attributes ~= " " ~ item; + + } + + // Element + else static if (is(Type : Element)) { + + content ~= item; + + } + + // String + else static if (isSomeString!Type) { + + content ~= escapeHTML(item); + + } + + // Range + else static if (isInputRange!Type) { + + // TODO Needs tests + foreach (content; item) addItem(content); + + } + + // No idea what is this + else static assert(false, "Unsupported element type " ~ fullyQualifiedName!Type); + + } + + unittest { + + void test(T)(T thing, string expectedResult) { + + Element elem; + elem.addItem(thing); + assert(elem == expectedResult); + + } + + test(`"Insecure" string`, ""Insecure" string"); + test(Element.make!"div", "
"); + test(Element.make!"?xml", ""); + + Element outer; + outer.addItem(Element.make!"div"); + outer.addItem(``); + test(outer, "
<XSS>"); + + } + + string toString() const { + + import std.conv; + + return startTag ~ attributes ~ trail ~ content ~ endTag; + + } + + alias toString this; + +} diff --git a/source/elemi/html.d b/source/elemi/html.d new file mode 100644 index 0000000..4476019 --- /dev/null +++ b/source/elemi/html.d @@ -0,0 +1,60 @@ +module elemi.html; + +import elemi.internal; + +public { + + import elemi.attribute; + import elemi.element; + +} + + +pure @safe: + + +// Magic elem alias +alias elem = elemH; + +/// Create a HTML element. +/// +/// Params: +/// name = Name of the element. +/// attrHTML = Unsanitized attributes to insert at compile-time. +/// attributes = Attributes for the element as an associative array mapping attribute names to their values. +/// content = Attributes (via `Attribute` and `attr`), children and text of the element. +/// Returns: a Element type, implictly castable to string. +Element elemH(string name, T...)(T args) { + + enum tag = makeHTMLTag(name); + + return elemX!(tag, T)(args); + +} + +/// Check if the given tag is a HTML5 self-closing tag. +bool isVoidTag(string tag) { + + switch (tag) { + + // Void element + case "area", "base", "br", "col", "command", "embed", "hr", "img", "input": + case "keygen", "link", "meta", "param", "source", "track", "wbr": + + return true; + + // Containers + default: + + return false; + + } + +} + +/// If the given tag is a void tag, make it a self-closing. +private string makeHTMLTag(string tag) { + + return isVoidTag(tag) ? tag ~ "/" : tag; + +} diff --git a/source/elemi/internal.d b/source/elemi/internal.d new file mode 100644 index 0000000..610ca53 --- /dev/null +++ b/source/elemi/internal.d @@ -0,0 +1,60 @@ +module elemi.internal; + +import std.conv; +import std.string; +import std.algorithm; + + +pure @safe: + + +/// Escape HTML elements. +/// +/// Package level: input sanitization is done automatically by the library. +package string escapeHTML(const string text) { + + // substitute doesn't work in CTFE for some reason + if (__ctfe) { + + return text + .replace(`<`, "<") + .replace(`>`, ">") + .replace(`&`, "&") + .replace(`"`, """) + .replace(`'`, "'"); + + } + + else return text.substitute!( + `<`, "<", + `>`, ">", + `&`, "&", + `"`, """, + `'`, "'", + ).to!string; + +} + +/// Serialize attributes +package string serializeAttributes(string[string] attributes) { + + // Generate attribute text + string attrHTML; + foreach (key, value; attributes) { + + attrHTML ~= format!` %s="%s"`(key, value.escapeHTML); + + } + + return attrHTML; + +} + +package string minifyAttributes(string attrHTML) { + + return attrHTML.splitter("\n") + .map!q{ a.strip } + .filter!q{ a.length } + .join(" "); + +} diff --git a/source/elemi/xml.d b/source/elemi/xml.d new file mode 100644 index 0000000..33259a7 --- /dev/null +++ b/source/elemi/xml.d @@ -0,0 +1,71 @@ +module elemi.xml; + +import elemi.internal; + +public { + + import elemi.attribute; + import elemi.element; + +} + + +pure @safe: + + +// Magic elem alias +alias elem = elemX; + +/// Create an XML element. +/// +/// Params: +/// name = Name of the element. +/// attrHTML = Unsanitized attributes to insert at compile-time. +/// attributes = Attributes for the element as an associative array mapping attribute names to their values. +/// content = Attributes (via `Attribute` and `attr`), children and text of the element. +/// Returns: a Element type, implictly castable to string. +Element elemX(string name, string[string] attributes, Ts...)(Ts content) { + + // Overload 1: attributes from a CTFE hash map + + enum attrHTML = attributes.serializeAttributes; + + auto element = Element.make!name; + element.attributes = attrHTML; + element ~= content; + + return element; + +} + +/// ditto +Element elemX(string name, string attrHTML = null, T...)(string[string] attributes, T content) { + + // Overload 2: attributes from a CTFE attribute string and from a runtime hash map + + enum attrHTML = minifyAttributes(attrHTML) + ~ attributes.serializeAttributes; + + auto element = Element.make!name; + element.attributes = attrHTML; + element ~= content; + + return element; + +} + +/// ditto +Element elemX(string name, string attrHTML = null, T...)(T content) +if (!T.length || (!is(T[0] == typeof(null)) && !is(T[0] == string[string]))) { + + // Overload 3: attributes from a CTFE attribute string + + enum attrHTML = minifyAttributes(attrHTML); + + auto element = Element.make!name; + element.attributes = attrHTML; + element ~= content; + + return element; + +} From 84fc31523f0b766809ae6e28bdd50e33d056d934 Mon Sep 17 00:00:00 2001 From: Artha Date: Tue, 30 Nov 2021 19:12:17 +0100 Subject: [PATCH 2/4] Re-add tests, move everything, make sure it works --- source/elemi.d | 921 --------------------------------------- source/elemi/attribute.d | 19 +- source/elemi/element.d | 135 +++++- source/elemi/html.d | 51 ++- source/elemi/internal.d | 6 +- source/elemi/package.d | 249 +++++++++++ source/elemi/xml.d | 152 ++++++- 7 files changed, 585 insertions(+), 948 deletions(-) delete mode 100644 source/elemi.d create mode 100644 source/elemi/package.d diff --git a/source/elemi.d b/source/elemi.d deleted file mode 100644 index cefbe11..0000000 --- a/source/elemi.d +++ /dev/null @@ -1,921 +0,0 @@ -module elemi_old; - -import std.conv; -import std.string; -import std.algorithm; - - -pure @safe: - -version (none): - - -/// Escape HTML elements. -/// -/// Package level: input sanitization is done automatically by the library. -package string escapeHTML(const string text) { - - if (__ctfe) { - - return text - .replace(`<`, "<") - .replace(`>`, ">") - .replace(`&`, "&") - .replace(`"`, """) - .replace(`'`, "'"); - - } - - else return text.substitute!( - `<`, "<", - `>`, ">", - `&`, "&", - `"`, """, - `'`, "'", - ).to!string; - -} - -/// Serialize attributes -package string serializeAttributes(string[string] attributes) { - - // Generate attribute text - string attrHTML; - foreach (key, value; attributes) { - - attrHTML ~= format!` %s="%s"`(key, value.escapeHTML); - - } - - return attrHTML; - -} - -/// Process the given content, filtering it to attributes. -package string processAttributes(T...)(T content) { - - string attrText; - static foreach (i, Type; T) { - - static if (is(Type : Attribute)) { - - attrText ~= " " ~ content; - - } - - } - - return attrText; - -} - -/// Process the given content, sanitizing user input and passing in already created elements. -package string processContent(T...)(T content) -if (!T.length || !is(T[$-1] == bool)) { - - return processContent(content, true); - -} - -/// Ditto -package string processContent(T...)(T content, bool escape) { - - import std.range : isInputRange; - - string contentText; - static foreach (i, Type; T) { - - // Given an attribute, ignore it - static if (is(Type == Attribute)) { } - - // Given a string - else static if (is(Type == string) || is(Type == wstring) || is(Type == dstring)) { - - // Escape it and add - contentText ~= escape - ? content[i].escapeHTML - : content[i]; - - } - - // Given a range of elements (this is unsafe! needs a patch) - else static if (isInputRange!Type) { - - foreach (item; content[i]) { - - contentText ~= item; - - } - - } - - // Given an element, just add it - else contentText ~= content[i]; - } - - return contentText; - -} - -unittest { - - assert(processContent() == ""); - -} - -/// Represents a HTML element. -/// -/// Use [elem] to generate. -struct Element { - - // Commonly used elements - - /// Doctype info for HTML. - enum HTMLDoctype = elemX!("!DOCTYPE", "html"); - - /// XML declaration element. Uses version 1.1. - enum XMLDeclaration = elemX!("?xml", q{ version="1.1" encoding="UTF-8" }); - - /// Enables UTF-8 encoding for the document - enum EncodingUTF8 = elem!("meta", q{ - charset="utf-8" - }); - - /// A common head element for adjusting the viewport to mobile devices. - enum MobileViewport = elem!("meta", q{ - name="viewport" - content="width=device-width, initial-scale=1" - }); - - package { - - /// If true, this is a preprocessor directive like `` or ``. It's self-closing, and its content - /// is placed within the tag itself. - bool directive; - - /// Attribute string of the element. - string attrs; - - /// HTML of the element. - string html; - - /// Added at the end. - string postHTML; - - } - - pure @safe: - - private this(string name, string attributes = null, string content = null, - ElementType type = ElementType.startEndTag) - do { - - // Character denoting tag end - string trail; - - with (ElementType) - final switch (type) { - - // Start and end tag combo - case ElementType.startEndTag: - - // Add the end tag - trail = ">"; - postHTML = name.format!""; - break; - - // Create a self-closing tag - case ElementType.emptyElementTag: - - assert(content.length == 0, - "Self-closing tag " ~ name ~ " cannot have children, its content must be empty." - ~ format!"content(%s) = \"%(%s%)\""(content.length, content)); - - // Instead of a tag end, add a slash at the end of the beginning tag - // Also add a space if there are any attributes - trail = (attributes.length ? " " : "") ~ "/>"; - // Review note: The space is probably not necessary, but should be kept as Elemi output is not meant to - // change for the same code. - - break; - - // XML declaration - case ElementType.declarationTag: - - // Place everything within the tag, and add a question mark at the end - trail = " "; - postHTML = " ?>"; - directive = true; - - break; - - case ElementType.doctypeTag: - - // Place everything within the tag - trail = " "; - postHTML = ">"; - directive = true; - - break; - - } - - // Attributes in - if (attributes.length) { - - html = format!"<%s %s%s%s"(name, attributes.stripLeft, trail, content); - - } - - // No attributes - else html = format!"<%s%s%s"(name, trail, content); - - } - - unittest { - - const Element elem; - assert(elem == ""); - - } - - string toString() const { - - return directive - ? html.stripRight ~ postHTML - : html ~ postHTML; - - } - - /// Create a new element as a child - Element add(string name, string[string] attributes = null, T...)(T content) - if (!T.length || !is(T[0] == string[string])) { - - html ~= elem!(name, attributes)(content); - return this; - - } - - /// Ditto - Element add(string name, string attrHTML = null, T...)(string[string] attributes, T content) { - - html ~= elem!(name, attrHTML)(attributes, content); - return this; - - } - - /// Ditto - Element add(string name, string attrHTML, T...)(T content) - if (!T.length || (!is(T[0] == typeof(null)) && !is(T[0] == string[string]))) { - - html ~= elem!(name, attrHTML)(null, content); - return this; - - } - - /// Add a child - Element add(T...)(T content) { - - - html ~= content.processContent(!directive); - return this; - - } - - /// Add trusted HTML code as a child. - Element addTrusted(string code) { - - html ~= elemTrusted(code); - return this; - - } - - /// - unittest { - - assert( - elem!"p".addTrusted("test") - == "

test

" - ); - - } - - /// Create a new XML element as a child - Element addX(string name, string[string] attributes = null, T...)(T content) - if (!T.length || !is(T[0] == string[string])) { - - html ~= elemX!(name, attributes)(content); - return this; - - } - - /// Ditto - Element addX(string name, string attrHTML = null, T...)(string[string] attributes, T content) { - - html ~= elemX!(name, attrHTML)(attributes, content); - return this; - - } - - /// Ditto - Element addX(string name, string attrHTML, T...)(T content) - if (!T.length || (!is(T[0] == typeof(null)) && !is(T[0] == string[string]))) { - - html ~= elemX!(name, attrHTML)(null, content); - return this; - - } - - alias opOpAssign(string op = "~") = add; - - // Yes. This is legal. - alias toString this; - -} - -/// Create a HTML element. -/// -/// Params: -/// name = Name of the element. -/// attrHTML = Unsanitized attributes to insert. -/// attributes = Attributes for the element as an associative array mapping attribute names to their values. -/// children = Children and text of the element. -/// Returns: a Element type, implictly castable to string. -Element elem(string name, string[string] attributes = null, T...)(T content) -if (!T.length || !is(T[0] == string[string])) { - - // Overload 1: attributes from a CTFE hash map - - // Ensure attribute HTML is generated compile-time. - enum attrHTML = attributes.serializeAttributes; - - enum type = name.isVoidTag - ? ElementType.emptyElementTag - : ElementType.startEndTag; - - return Element(name, attrHTML, content.processContent, type); - -} - -/// Ditto -Element elem(string name, string attrHTML = null, T...)(string[string] attributes, T content) { - - // Overload 2: attributes from a CTFE attribute string and from a runtime hash map - - enum attrInput = attrHTML.splitter("\n") - .map!q{a.strip} - .filter!q{a.length} - .join(" "); - - enum type = name.isVoidTag - ? ElementType.emptyElementTag - : ElementType.startEndTag; - - return Element( - name, - attrInput ~ attributes.serializeAttributes, - content.processContent, - type, - ); - -} - -/// Ditto -Element elem(string name, string attrHTML, T...)(T content) -if (!T.length || (!is(T[0] == typeof(null)) && !is(T[0] == string[string]))) { - - import std.stdio; - - // Overload 3: attributes from a CTFE attribute string - - return elem!(name, attrHTML)(null, content); - -} - -/// -unittest { - - // Compile-time empty type detection - assert(elem!"input" == ""); - assert(elem!"hr" == "
"); - assert(elem!"p" == "

"); - - // Content - assert(elem!"p"("Hello, World!") == "

Hello, World!

"); - - // Compile-time attributes — variant A - assert( - - elem!("a", [ "href": "about:blank", "title": "Destroy this page" ])("Hello, World!") - - == `Hello, World!` - - ); - - // Compile-time attributes — variant B - assert( - - elem!("a", q{ - href="about:blank" - title="Destroy this page" })( - "Hello, World!" - ) - == `Hello, World!` - - ); - - // Nesting and input sanitization - assert( - - elem!"div"( - elem!"p"("Hello, World!"), - "-> Sanitized" - ) - - == "

Hello, World!

-> Sanitized
" - - ); - - // Sanitized user input in attributes - assert( - elem!"input"( - attr("type") = "text", - attr("value") = `"XSS!"` - ) == `` - ); - - assert( - - elem!"input"(["type": "text", "value": `"XSS!"`]) - == `` - - ); - assert( - elem!("input", q{ type="text" })(["value": `"XSS!"`]) - == `` - ); - - // Alternative method of nesting - assert( - - elem!("div", q{ style="background:#500" }) - .add!"p"("Hello, World!") - .add("-> Sanitized") - .add( - " and", - " clear" - ) - - == `

Hello, World!

-> Sanitized and clear
` - - ); - - import std.range : repeat; - - // Adding elements by ranges - assert( - elem!"ul"( - "element".elem!"li".repeat(3) - ) - == "
  • element
  • element
  • element
" - - ); - - // Significant whitespace - assert(elem!"span"(" Foo ") == " Foo "); - - // Also with tilde - auto myElem = elem!"div"; - myElem ~= elem!"span"("Sample"); - myElem ~= " "; - myElem ~= elem!"span"("Text"); - myElem ~= attr("class") = "test"; - - assert( - myElem == `
Sample Text
` - ); - -} - -/// A general example page -@system -unittest { - - import std.stdio : writeln; - import std.base64 : Base64; - - enum page = Element.HTMLDoctype ~ elem!"html"( - - elem!"head"( - - elem!("title")("An example document"), - - // Metadata - Element.MobileViewport, - Element.EncodingUTF8, - - elem!("style")(` - - html, body { - height: 100%; - font-family: sans-serif; - padding: 0; - margin: 0; - } - .header { - background: #f7a; - font-size: 1.5em; - margin: 0; - padding: 5px; - } - .article { - padding-left: 2em; - } - - `.split("\n").map!"a.strip".filter!"a.length".join), - - ), - - elem!"body"( - - elem!("header", q{ class="header" })( - elem!"h1"("Example website") - ), - - elem!"h1"("Welcome to my website!"), - elem!"p"("Hello there,", - elem!"br", "may you want to read some of my articles?"), - - elem!("div", q{ class="article" })( - elem!"h2"("Stuff"), - elem!"p"("Description") - ) - - ) - - ); - - enum target = cast(string) Base64.decode([ - "PCFET0NUWVBFIGh0bWw+PGh0bWw+PGhlYWQ+PHRpdGxlPkFuIGV4YW1wbGUgZG9jdW", - "1lbnQ8L3RpdGxlPjxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1k", - "ZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MSIgLz48bWV0YSBjaGFyc2V0PSJ1dG", - "YtOCIgLz48c3R5bGU+aHRtbCwgYm9keSB7aGVpZ2h0OiAxMDAlO2ZvbnQtZmFtaWx5", - "OiBzYW5zLXNlcmlmO3BhZGRpbmc6IDA7bWFyZ2luOiAwO30uaGVhZGVyIHtiYWNrZ3", - "JvdW5kOiAjZjdhO2ZvbnQtc2l6ZTogMS41ZW07bWFyZ2luOiAwO3BhZGRpbmc6IDVw", - "eDt9LmFydGljbGUge3BhZGRpbmctbGVmdDogMmVtO308L3N0eWxlPjwvaGVhZD48Ym", - "9keT48aGVhZGVyIGNsYXNzPSJoZWFkZXIiPjxoMT5FeGFtcGxlIHdlYnNpdGU8L2gx", - "PjwvaGVhZGVyPjxoMT5XZWxjb21lIHRvIG15IHdlYnNpdGUhPC9oMT48cD5IZWxsby", - "B0aGVyZSw8YnIvPm1heSB5b3Ugd2FudCB0byByZWFkIHNvbWUgb2YgbXkgYXJ0aWNs", - "ZXM/PC9wPjxkaXYgY2xhc3M9ImFydGljbGUiPjxoMj5TdHVmZjwvaDI+PHA+RGVzY3", - "JpcHRpb248L3A+PC9kaXY+PC9ib2R5PjwvaHRtbD4=", - ].join); - - assert(page == target); - -} - -/// Represents an attribute. -struct Attribute { - - /// Name of the attribute. - string name; - - /// Value assigned to the attribute. - string value; - - /// Assign a new value - void opAssign(string newValue) { - - value = newValue; - - } - - void opAssign(string[] newValues) { - - value = newValues.join; - - } - - string toString() { - - return format!q{%s="%s"}(name, value.escapeHTML); - - } - -} - -/// Create an attribute -Attribute attr(string name) { - return Attribute(name); -} - -/// ditto -Attribute attr(string name)() { - return Attribute(name); -} - - -unittest { - - assert(elem!"div"( - attr("id") = "name", - attr("class") = ["hello", "world"], - ) == `
`); - -} - -// README example -unittest { - - import elemi; - - auto document = Element.HTMLDoctype ~ elem!"html"( - - elem!"head"( - elem!"title"("Hello, World!"), - Element.MobileViewport, - Element.EncodingUTF8, - ), - - elem!"body"( - attr("class") = ["home", "logged-in"], - - elem!"main"( - - elem!"img"( - attr("src") = "/logo.png", - attr("alt") = "Website logo" - ), - - // All input is sanitized. - "" - - ) - - ), - - ); - - auto xml = Element.XMLDeclaration ~ elemX!("feed", `xmlns="http://www.w3.org/2005/Atom"`)( - - elemX!"title"("Example feed"), - elemX!"subtitle"("Showcasing using elemi for generating XML"), - elemX!"updated"("2021-10-30T20:30:00Z"), - - elemX!"entry"( - elemX!"title"("Elemi home page"), - elemX!("link", `href="https://github.com/Soaku/Elemi"`), - elemX!"updated"("2021-10-30T20:30:00Z"), - elemX!"summary"("Elemi repository on GitHub"), - elemX!"author"( - elemX!"Soaku", - elemX!"soaku@samerion.com" - ) - ) - - ); - -} - -/// Create an element from trusted HTML/XML code. -/// -/// Warning: This element cannot have children added after being created. They will be added as siblings instead. -Element elemTrusted(string code) { - - Element element; - element.html = code; - return element; - -} - -/// -unittest { - - assert(elemTrusted("

test

") == "

test

"); - assert( - elem!"p"( - elemTrusted("foobar"), - ) == "

foobar

" - ); - assert( - elemTrusted("test").add("foo") - == "test<b>foo</b>" - ); - -} - -/// Create an XML element. -/// -/// Params: -/// name = Name of the element. If the name ends with a slash, the tag will be made self-closing and will not accept -/// children. -/// attrHTML = Unsanitized attributes to insert. -/// attributes = Attributes for the element, as an. -/// children = Children and text of the element. -/// Returns: a Element type, implictly castable to string. -Element elemX(string name, string[string] attributes = null, T...)(T content) -if (!T.length || !is(T[0] == string[string])) { - - // Overload 1: attributes from a CTFE hash map - - // Ensure attribute code is generated compile-time. - enum attrHTML = attributes.serializeAttributes; - - alias data = XMLTagData!name; - - return Element(data.name, attrHTML, content.processContent(data.sanitize), data.type); - -} - -/// Ditto -Element elemX(string name, string attrHTML = null, T...)(string[string] attributes, T content) { - - // Overload 2: attributes from a CTFE attribute string and from a runtime hash map - - enum attrInput = attrHTML.splitter("\n") - .map!q{a.strip} - .filter!q{a.length} - .join(" "); - - alias data = XMLTagData!name; - - return Element( - data.name, - attrInput ~ attributes.serializeAttributes, - content.processContent(data.sanitize), - data.type, - ); - -} - -/// Ditto -Element elemX(string name, string attrHTML, T...)(T content) -if (!T.length || (!is(T[0] == typeof(null)) && !is(T[0] == string[string]))) { - - import std.stdio; - - // Overload 3: attributes from a CTFE attribute string - - return elemX!(name, attrHTML)(null, content); - -} - -/// -unittest { - - enum xml = elemX!"xml"( - elemX!"heading"("This is my sample document!"), - elemX!("spacing /", q{ height="1em" }), - elemX!"spacing /"(["height": "1em"]), - elemX!"empty", - elemX!"br", - elemX!"container" - .addX!"paragraph"("Foo") - .addX!"paragraph"("Bar"), - ); - - assert(xml == "" ~ ( - "This is my sample document!" - ~ `` - ~ `` - ~ `` - ~ `

` - ~ "" ~ ( - "Foo" - ~ "Bar" - ) ~ "" - ) ~ "
"); - -} - -/// Check if the given tag is a HTML5 self-closing tag. -bool isVoidTag(string tag) { - - switch (tag) { - - // Void element - case "area", "base", "br", "col", "command", "embed", "hr", "img", "input": - case "keygen", "link", "meta", "param", "source", "track", "wbr": - - return true; - - // Containers - default: - - return false; - - } - -} - -enum ElementType { - - startEndTag, - emptyElementTag, - declarationTag, - doctypeTag, - -} - -/// Get XML element data from Elemi tag name notation. -template XMLTagData(string tag) { - - static if (tag.startsWith("?")) { - - enum name = tag; - enum type = ElementType.declarationTag; - enum sanitize = false; - - } - - else static if (tag.startsWith("!")) { - - enum name = tag; - enum type = ElementType.doctypeTag; - enum sanitize = false; - - } - - else static if (tag.endsWith("/")) { - - enum name = tag[0..$-1].stripRight; - enum type = ElementType.emptyElementTag; - enum sanitize = true; - - } - - else { - - enum name = tag; - enum type = ElementType.startEndTag; - enum sanitize = true; - - } - -} - -unittest { - - void assertValid(string tag, string expectedString, ElementType expectedType)() { - - alias data = XMLTagData!tag; - - enum fmt = "wrong %s result for string \"%s\", %s vs expected %s"; - - assert(data.name == expectedString, format!fmt("string", tag, data.name, expectedString)); - assert(data.type == expectedType, format!fmt("type", tag, data.type, expectedType)); - - } - - with (ElementType) { - - assertValid!("br", "br", startEndTag); - assertValid!("br ", "br ", startEndTag); - assertValid!("br /", "br", emptyElementTag); - assertValid!("br/", "br", emptyElementTag); - - assertValid!("myFancyTag", "myFancyTag", startEndTag); - assertValid!("myFancyTag /", "myFancyTag", emptyElementTag); - assertValid!("myFancyTäg /", "myFancyTäg", emptyElementTag); - - assertValid!("?br", "?br", declarationTag); - assertValid!("!br", "!br", doctypeTag); - - } - -} - -// Issue #1 -unittest { - - enum Foo = elem!("p")("code"); - -} - -unittest { - - assert(elemX!"p" == "

"); - assert(elemX!"p /" == "

"); - assert(elemX!("!DOCTYPE", "html") == ""); - assert(Element.HTMLDoctype == ""); - assert(elemX!("!ATTLIST", "pre (preserve) #FIXED 'preserve'") == ""); - assert(elemX!"!ATTLIST"("pre (preserve) #FIXED 'preserve'") == ""); - assert(elemX!"!ATTLIST".add("pre (preserve) #FIXED 'preserve'") == ""); - assert(elemX!"?xml" == ""); - assert(elemX!("?xml", q{ version="1.1" encoding="UTF-8" }) == ``); - assert(elemX!"?xml"(`version="1.1" encoding="UTF-8"`) == ``); - assert(elemX!"?xml".add(`version="1.1" encoding="UTF-8"`) == ``); - assert(Element.XMLDeclaration == ``); - assert(elemX!"?xml"(["version": "1.1"]).addTrusted(`encoding="UTF-8"`) - == ``); - assert(elemX!"?php" == ""); - assert(elemX!"?php"(`echo "Hello, World!";`) == ``); - assert(elemX!"?="(`"Hello, World!"`) == ``); - // ↑ I will not special-case this to remove spaces. - - auto php = elemX!"?php"; - php.add(`$target = "World!";`); - php.add(`echo "Hello, " . $target;`); - assert(php == ``); - -} diff --git a/source/elemi/attribute.d b/source/elemi/attribute.d index 214d35d..c38bc57 100644 --- a/source/elemi/attribute.d +++ b/source/elemi/attribute.d @@ -20,15 +20,17 @@ struct Attribute { string value; /// Assign a new value - void opAssign(string newValue) { + Attribute opAssign(string newValue) { value = newValue; + return this; } - void opAssign(string[] newValues) { + Attribute opAssign(string[] newValues) { - value = newValues.join; + value = newValues.join(" "); + return this; } @@ -65,3 +67,14 @@ Attribute attr(string name)(string value) { return Attribute(name, value); } + +unittest { + + import elemi.html; + + assert(elem!"div"( + attr("id") = "name", + attr("class") = ["hello", "world"], + ) == `

`); + +} diff --git a/source/elemi/element.d b/source/elemi/element.d index 4bdee34..f1261c7 100644 --- a/source/elemi/element.d +++ b/source/elemi/element.d @@ -2,19 +2,16 @@ module elemi.element; import std.string; -import elemi.xml; +import elemi; import elemi.internal; pure @safe: -/// A collection to hold multiple elements next to each other without a wrapper element. -enum elems = Element(); - /// Represents a HTML element. /// -/// Use [elem] to generate. +/// Use `elem` to generate. struct Element { pure: @@ -23,7 +20,7 @@ struct Element { enum { /// Doctype info for HTML. - HTMLDoctype = elemX!"!DOCTYPE"("html"), + HTMLDoctype = elemX!("!DOCTYPE", "html"), /// XML declaration element. Uses version 1.1. XMLDeclaration = elemX!"?xml"( @@ -32,12 +29,12 @@ struct Element { ), /// Enables UTF-8 encoding for the document - EncodingUTF8 = elemX!"meta"( + EncodingUTF8 = elemH!"meta"( attr!"charset" = "utf-8", ), /// A common head element for adjusting the viewport to mobile devices. - MobileViewport = elemX!"meta"( + MobileViewport = elemH!"meta"( attr!"name" = "viewport", attr!"content" = "width=device-width, initial-scale=1" ), @@ -46,6 +43,7 @@ struct Element { package { + bool directive; string startTag; string attributes; string trail; @@ -77,7 +75,8 @@ struct Element { that.startTag = format!"<%s"(tagName); that.trail = " "; - that.endTag = "?>"; + that.endTag = " ?>"; + that.directive = true; } @@ -87,6 +86,7 @@ struct Element { that.startTag = format!"<%s"(tagName); that.trail = " "; that.endTag = ">"; + that.directive = true; } @@ -102,6 +102,15 @@ struct Element { } + /// Add trusted XML/HTML code as a child of this node. + /// Returns: This node, to allow chaining. + Element addTrusted(string code) { + + content ~= code; + return this; + + } + void opOpAssign(string op = "~", Ts...)(Ts args) { // Check each argument @@ -136,7 +145,7 @@ struct Element { // String else static if (isSomeString!Type) { - content ~= escapeHTML(item); + content ~= directive ? item : escapeHTML(item); } @@ -155,22 +164,20 @@ struct Element { unittest { - void test(T)(T thing, string expectedResult) { + void test(T...)(T things, string expectedResult) { Element elem; - elem.addItem(thing); - assert(elem == expectedResult); + elem ~= things; + assert(elem == expectedResult, format!"wrong result: `%s`"(elem.toString)); } test(`"Insecure" string`, ""Insecure" string"); test(Element.make!"div", "
"); test(Element.make!"?xml", ""); + test(Element.make!"div", ``, "
<XSS>"); - Element outer; - outer.addItem(Element.make!"div"); - outer.addItem(``); - test(outer, "
<XSS>"); + test(["hello, ", "!"], "hello, <XSS>!"); } @@ -178,6 +185,13 @@ struct Element { import std.conv; + // Special case: prevent space between trail and endTag in directives + if (directive && content == null) { + + return startTag ~ attributes ~ content ~ endTag; + + } + return startTag ~ attributes ~ trail ~ content ~ endTag; } @@ -185,3 +199,90 @@ struct Element { alias toString this; } + +/// Creates an element to function as an element collection to place within other elements. This is functionally +/// equivalent to a regular element, server-side, but is transparent for the rendered document. +Element elems(T...)(T content) { + + Element element; + element ~= content; + return element; + +} + +/// +unittest { + + const collection = elems("Hello, ", elem!"span"("world!")); + + assert(collection == `Hello, world!`); + assert(elem!"div"(collection) == `
Hello, world!
`); + +} + +/// Create an element from trusted HTML/XML code. +/// +/// Warning: This element cannot have children added after being created. They will be added as siblings instead. +Element elemTrusted(string code) { + + Element element; + element.content = code; + return element; + +} + +/// +unittest { + + assert(elemTrusted("

test

") == "

test

"); + assert( + elem!"p"( + elemTrusted("foobar"), + ) == "

foobar

" + ); + assert( + elemTrusted("test").add("foo") + == "test<b>foo</b>" + ); + +} + + +// Other related tests + +unittest { + + const Element element; + assert(element == ""); + assert(element == elems()); + assert(element == Element()); + + assert(elems("