diff --git a/src/Html/Component.php b/src/Html/Component.php
new file mode 100644
index 0000000..ff9a8b9
--- /dev/null
+++ b/src/Html/Component.php
@@ -0,0 +1,8 @@
+*/
+ private $attributes;
+
+ /** @var Component[]|null */
+ private $Contents;
+
+ /**
+ * @param string $name
+ * @param array $attributes
+ * @param Component[]|null $Contents
+ */
+ public function __construct(
+ string $name,
+ array $attributes,
+ ?array $Components
+ ) {
+ $this->name = $name;
+ $this->attributes = $attributes;
+ $this->Components = $Components;
+ }
+
+ /**
+ * @param string $name
+ * @param array $attributes
+ */
+ public static function selfClosing(string $name, array $attributes): self
+ {
+ return new self($name, $attributes, null);
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * @return Component[]|null
+ */
+ public function contents(): ?array
+ {
+ return $this->Contents;
+ }
+
+ public function getHtml(): string
+ {
+ $html = '';
+
+ $elementName = CharacterFilter::htmlElementName($this->name);
+
+ $html .= '<' . $elementName;
+
+ if (! empty($this->attributes)) {
+ foreach ($this->attributes as $name => $value) {
+ $html .= ' '
+ . CharacterFilter::htmlAttributeName($name)
+ . '="'
+ . Escaper::htmlAttributeValue($value)
+ . '"'
+ ;
+ }
+ }
+
+ if ($this->Contents !== null) {
+ $html .= '>';
+
+ if (! empty($this->Contents)) {
+ $html .= "\n";
+
+ foreach ($this->Contents as $C) {
+ $html .= $C->getHtml();
+ }
+ }
+
+ $html .= "" . $elementName . ">\n";
+ } else {
+ $html .= ' />';
+ }
+
+ return $html;
+ }
+}
diff --git a/src/Html/Components/Text.php b/src/Html/Components/Text.php
new file mode 100644
index 0000000..1737cf2
--- /dev/null
+++ b/src/Html/Components/Text.php
@@ -0,0 +1,23 @@
+text = $text;
+ }
+
+ public function getHtml(): string
+ {
+ return Escaper::htmlElementValue($text);
+ }
+}
diff --git a/src/Html/Sanitisation/CharacterFilter.php b/src/Html/Sanitisation/CharacterFilter.php
new file mode 100644
index 0000000..441bbb7
--- /dev/null
+++ b/src/Html/Sanitisation/CharacterFilter.php
@@ -0,0 +1,36 @@
+),
+ * U+002F SOLIDUS (/), and U+003D EQUALS SIGN (=) characters,
+ * the control characters, and any characters that are not defined by
+ * Unicode.
+ */
+ return \preg_replace(
+ '/(?:[[:space:]\0"\'>\/=[:cntrl:]]|[^\pC\pL\pM\pN\pP\pS\pZ])++/iu',
+ '',
+ $text
+ );
+ }
+
+ public static function htmlElementName(string $text) : string
+ {
+ /**
+ * https://www.w3.org/TR/html/syntax.html#tag-name
+ *
+ * HTML elements all have names that only use alphanumeric
+ * ASCII characters.
+ */
+ return \preg_replace('/[^[:alnum:]]/', '', $text);
+ }
+}
diff --git a/src/Html/Sanitisation/Escaper.php b/src/Html/Sanitisation/Escaper.php
new file mode 100644
index 0000000..3f19a5d
--- /dev/null
+++ b/src/Html/Sanitisation/Escaper.php
@@ -0,0 +1,27 @@
+