Added prototype BBCode -> HTML parser
authorAlexander Ebert <ebert@woltlab.com>
Sat, 14 May 2016 21:24:21 +0000 (23:24 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Sat, 14 May 2016 21:24:42 +0000 (23:24 +0200)
21 files changed:
wcfsetup/install/files/lib/form/MessageForm.class.php
wcfsetup/install/files/lib/system/bbcode/HtmlBBCodeParser.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/input/HtmlInputProcessor.class.php
wcfsetup/install/files/lib/system/html/input/filter/MessageHtmlInputFilter.class.php
wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeProcessor.class.php
wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeWoltlabMention.class.php
wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeWoltlabMetacode.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeWoltlabMetacodeMarker.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/input/node/IHtmlInputNode.class.php
wcfsetup/install/files/lib/system/html/metacode/converter/AbstractMetacodeConverter.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/metacode/converter/ColorMetacodeConverter.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/metacode/converter/IMetacodeConverter.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/metacode/converter/QuoteMetacodeConverter.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/node/AbstractHtmlNode.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/node/HtmlNodeProcessor.class.php
wcfsetup/install/files/lib/system/html/node/IHtmlNode.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/output/HtmlOutputNodeProcessor.class.php
wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeBlockquote.class.php
wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeWoltlabMention.class.php
wcfsetup/install/files/lib/system/html/output/node/IHtmlOutputNode.class.php
wcfsetup/install/files/lib/util/DOMUtil.class.php [new file with mode: 0644]

index 677de11f4a5318635cfd5d099e45e46024d52934..04b68237b6eb36b5e3da0e8ed03abe5f1b43d865 100644 (file)
@@ -267,13 +267,13 @@ abstract class MessageForm extends AbstractCaptchaForm {
                        throw new UserInputException('text', 'tooLong');
                }
                
-               if ($this->enableBBCodes && $this->allowedBBCodesPermission) {
+               /*if ($this->enableBBCodes && $this->allowedBBCodesPermission) {
                        $disallowedBBCodes = BBCodeParser::getInstance()->validateBBCodes($this->text, ArrayUtil::trim(explode(',', WCF::getSession()->getPermission($this->allowedBBCodesPermission))));
                        if (!empty($disallowedBBCodes)) {
                                WCF::getTPL()->assign('disallowedBBCodes', $disallowedBBCodes);
                                throw new UserInputException('text', 'disallowedBBCodes');
                        }
-               }
+               }*/
                
                // search for censored words
                if (ENABLE_CENSORSHIP) {
diff --git a/wcfsetup/install/files/lib/system/bbcode/HtmlBBCodeParser.class.php b/wcfsetup/install/files/lib/system/bbcode/HtmlBBCodeParser.class.php
new file mode 100644 (file)
index 0000000..f164b31
--- /dev/null
@@ -0,0 +1,205 @@
+<?php
+namespace wcf\system\bbcode;
+use wcf\system\exception\SystemException;
+use wcf\util\JSON;
+use wcf\util\StringUtil;
+
+/**
+ * Parses bbcodes and transforms them into the custom HTML element <woltlab-bbcode>
+ * that can be safely passed through HTMLPurifier's validation mechanism.
+ * 
+ * All though not exactly required for all bbcodes, the actual output of an bbcode
+ * cannot be forseen and potentially conflict with HTMLPurifier's whitelist. Examples
+ * are <iframe> or other embedded media that is allowed as a result of a bbcode, but
+ * not allowed to be directly provided by a user. 
+ * 
+ * @author     Alexander Ebert
+ * @copyright  2001-2016 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    com.woltlab.wcf
+ * @subpackage system.bbcode
+ * @category   Community Framework
+ */
+class HtmlBBCodeParser extends BBCodeParser {
+       /**
+        * list of open tags with name and uuid
+        * @var array
+        */
+       protected $openTagIdentifiers = [];
+       
+       /**
+        * regex for valid bbcode names
+        * @var string
+        */
+       protected $validBBCodePattern = '~^[a-z](?:[a-z0-9\-_]+)?$~';
+       
+       /**
+        * @inheritDoc
+        */
+       public function parse($text) {
+               $this->setText($text);
+               
+               // difference to the original implementation: sourcecode bbcodes are handled too
+               $this->buildTagArray(false);
+               
+               $this->buildXMLStructure();
+               $this->buildParsedString();
+               
+               return $this->parsedText;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function buildParsedString() {
+               // reset parsed text
+               $this->parsedText = '';
+               
+               // reset identifiers for open tags
+               $this->openTagIdentifiers = [];
+               
+               // create text buffer
+               $buffer =& $this->parsedText;
+               
+               // stack of buffered tags
+               $bufferedTagStack = array();
+               
+               // loop through the tags
+               $i = -1;
+               foreach ($this->tagArray as $i => $tag) {
+                       // append text to buffer
+                       $buffer .= $this->textArray[$i];
+                       
+                       if ($tag['closing']) {
+                               // get buffered opening tag
+                               $openingTag = end($bufferedTagStack);
+                               
+                               // closing tag
+                               if ($openingTag && $openingTag['name'] == $tag['name']) {
+                                       $hideBuffer = false;
+                                       // insert buffered content as attribute value
+                                       foreach ($this->bbcodes[$tag['name']]->getAttributes() as $attribute) {
+                                               if ($attribute->useText && !isset($openingTag['attributes'][$attribute->attributeNo])) {
+                                                       $openingTag['attributes'][$attribute->attributeNo] = $buffer;
+                                                       $hideBuffer = true;
+                                                       break;
+                                               }
+                                       }
+                                       
+                                       // validate tag attributes again
+                                       if ($this->isValidTag($openingTag)) {
+                                               // build tag
+                                               if ($this->bbcodes[$tag['name']]->className) {
+                                                       // difference to the original implementation: use the custom HTML element than to process them directly
+                                                       $parsedTag = $this->compileTag($openingTag, $buffer, $tag);
+                                               }
+                                               else {
+                                                       // build tag
+                                                       $parsedTag = $this->buildOpeningTag($openingTag);
+                                                       $closingTag = $this->buildClosingTag($tag);
+                                                       if (!empty($closingTag) && $hideBuffer) $parsedTag .= $buffer.$closingTag;
+                                               }
+                                       }
+                                       else {
+                                               $parsedTag = $openingTag['source'].$buffer.$tag['source'];
+                                       }
+                                       
+                                       // close current buffer
+                                       array_pop($bufferedTagStack);
+                                       
+                                       // open previous buffer
+                                       if (count($bufferedTagStack) > 0) {
+                                               $bufferedTag =& $bufferedTagStack[count($bufferedTagStack) - 1];
+                                               $buffer =& $bufferedTag['buffer'];
+                                       }
+                                       else {
+                                               $buffer =& $this->parsedText;
+                                       }
+                                       
+                                       // append parsed tag
+                                       $buffer .= $parsedTag;
+                               }
+                               else {
+                                       $buffer .= $this->buildClosingTag($tag);
+                               }
+                       }
+                       else {
+                               // opening tag
+                               if ($this->needBuffering($tag)) {
+                                       // start buffering
+                                       $tag['buffer'] = '';
+                                       $bufferedTagStack[] = $tag;
+                                       $buffer =& $bufferedTagStack[(count($bufferedTagStack) - 1)]['buffer'];
+                               }
+                               else {
+                                       $buffer .= $this->buildOpeningTag($tag);
+                               }
+                       }
+               }
+               
+               if (isset($this->textArray[$i + 1])) $this->parsedText .= $this->textArray[$i + 1];
+       }
+       
+       /**
+        * Compiles tag fragments into the custom HTML element.
+        * 
+        * @param       array   $openingTag     opening tag data
+        * @param       string  $content        content between opening and closing tag
+        * @param       array   $closingTag     closing tag data
+        * @return      string  custom HTML element
+        */
+       protected function compileTag(array $openingTag, $content, array $closingTag) {
+               return $this->buildOpeningTag($openingTag) . $content . $this->buildClosingTag($closingTag);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function buildOpeningTag(array $tag) {
+               $name = strtolower($tag['name']);
+               if (!$this->isValidBBCodeName($name)) {
+                       return $tag['source'];
+               }
+               
+               $uuid = StringUtil::getUUID();
+               $this->openTagIdentifiers[] = [
+                       'name' => $name,
+                       'uuid' => $uuid
+               ];
+               
+               $attributes = '';
+               if (!empty($tag['attributes'])) {
+                       // uses base64 encoding to avoid an "escape" nightmare
+                       $attributes = ' data-attributes="' . base64_encode(JSON::encode($tag['attributes'])) . '"';
+               }
+               
+               return '<woltlab-metacode-marker data-name="' . $name . '" data-uuid="' . $uuid . '"' . $attributes . ' />';
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected function buildClosingTag(array $tag) {
+               $name = strtolower($tag['name']);
+               if (!$this->isValidBBCodeName($name)) {
+                       return $tag['source'];
+               }
+               
+               $data = array_pop($this->openTagIdentifiers);
+               if ($data['name'] !== $name) {
+                       throw new SystemException("Tag mismatch, expected '".$name."', got '".$data['name']."'.");
+               }
+               
+               return '<woltlab-metacode-marker data-uuid="' . $data['uuid'] . '" />';
+       }
+       
+       /**
+        * Returns true if provided name is a valid bbcode identifier.
+        * 
+        * @param       string          $name           bbcode identifier
+        * @return      boolean         true if provided name is a valid bbcode identifier
+        */
+       protected function isValidBBCodeName($name) {
+               return preg_match($this->validBBCodePattern, $name) === 1;
+       }
+}
index aa5e557dfc9467a1cc1fd5ef0b581ad1884044ce..7847d442fedb54b9dbf7cbb0e92ddc792230fbb8 100644 (file)
@@ -1,8 +1,10 @@
 <?php
 namespace wcf\system\html\input;
+use wcf\system\bbcode\HtmlBBCodeParser;
 use wcf\system\html\input\filter\IHtmlInputFilter;
 use wcf\system\html\input\filter\MessageHtmlInputFilter;
 use wcf\system\html\input\node\HtmlInputNodeProcessor;
+use wcf\util\StringUtil;
 
 /**
  * TOOD documentation
@@ -20,6 +22,12 @@ class HtmlInputProcessor {
        protected $htmlInputNodeProcessor;
        
        public function process($html) {
+               // enforce consistent newlines
+               $html = StringUtil::unifyNewlines($html);
+               
+               // transform bbcodes into metacode markers
+               $html = HtmlBBCodeParser::getInstance()->parse($html);
+               
                // filter HTML
                $html = $this->getHtmlInputFilter()->apply($html);
                
index c18f96719573194f66e16ac2688dcfcb1bd80cda..5e4a30a998ff93a46a817b76e29c22fb3fd71b46 100644 (file)
@@ -38,5 +38,16 @@ class MessageHtmlInputFilter implements IHtmlInputFilter {
                        'data-user-id' => 'Number',
                        'data-username' => 'Text'
                ]);
+               
+               $definition->addElement('woltlab-metacode', 'Inline', 'Inline', '', [
+                       'data-attributes' => 'Text',
+                       'data-name' => 'Text'
+               ]);
+               
+               $definition->addElement('woltlab-metacode-marker', 'Inline', 'Empty', '', [
+                       'data-attributes' => 'Text',
+                       'data-name' => 'Text',
+                       'data-uuid' => 'Text'
+               ]);
        }
 }
index d6d64a40321acf1f9b57c476cd5554b77795b79d..a00d92a41b511d5b6582ac7a6b5dda0a8b233e73 100644 (file)
@@ -7,14 +7,11 @@ use wcf\system\html\node\HtmlNodeProcessor;
  * @since      2.2
  */
 class HtmlInputNodeProcessor extends HtmlNodeProcessor {
-       public function load($html) {
-               parent::load($html);
-               
-               $this->nodeData = [];
-       }
-       
        public function process() {
-               $woltlabMention = new HtmlInputNodeWoltlabMention();
-               $woltlabMention->process($this);
+               // process metacode markers first
+               $this->invokeHtmlNode(new HtmlInputNodeWoltlabMetacodeMarker());
+               
+               // handle static converters
+               $this->invokeHtmlNode(new HtmlInputNodeWoltlabMetacode());
        }
 }
index 352a79c013d11e67c5853a4a76a9a656f6648d18..9387589802fbd1840bb3717643bbb886e1b3a1fb 100644 (file)
@@ -1,16 +1,20 @@
 <?php
 namespace wcf\system\html\input\node;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
 
 /**
  * TOOD documentation
  * @since      2.2
  */
-class HtmlInputNodeWoltlabMention implements IHtmlInputNode {
-       public function process(HtmlInputNodeProcessor $htmlInputNodeProcessor) {
+class HtmlInputNodeWoltlabMention extends AbstractHtmlNode {
+       protected $tagName = 'woltlab-mention';
+       
+       public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
                $userIds = [];
                
                /** @var \DOMElement $mention */
-               foreach ($htmlInputNodeProcessor->getDocument()->getElementsByTagName('woltlab-mention') as $mention) {
+               foreach ($elements as $mention) {
                        $userId = intval($mention->getAttribute('data-user-id'));
                        if ($userId) {
                                $userIds[] = $userId;
@@ -21,4 +25,8 @@ class HtmlInputNodeWoltlabMention implements IHtmlInputNode {
                        
                }
        }
+       
+       public function replaceTag(array $data) {
+               return null;
+       }
 }
diff --git a/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeWoltlabMetacode.class.php b/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeWoltlabMetacode.class.php
new file mode 100644 (file)
index 0000000..412d814
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+namespace wcf\system\html\input\node;
+use wcf\system\bbcode\HtmlBBCodeParser;
+use wcf\system\exception\SystemException;
+use wcf\system\html\metacode\converter\IMetacodeConverter;
+use wcf\system\html\metacode\converter\SimpleMetacodeConverter;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
+use wcf\util\DOMUtil;
+use wcf\util\StringUtil;
+
+/**
+ * TOOD documentation
+ * @since      2.2
+ */
+class HtmlInputNodeWoltlabMetacode extends AbstractHtmlNode {
+       /**
+        * static mapping of attribute-less metacodes that map to
+        * an exact HTML tag without the need of further processing
+        *
+        * @var string[]
+        */
+       public $simpleMapping = [
+               'b' => 'strong',
+               'i' => 'em',
+               'tt' => 'kbd',
+               'u' => 'u'
+       ];
+       
+       protected $tagName = 'woltlab-metacode';
+       
+       public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
+               /** @var IMetacodeConverter[] $converters */
+               $converters = [];
+               
+               /** @var \DOMElement $element */
+               foreach ($elements as $element) {
+                       $name = $element->getAttribute('data-name');
+                       if ($name === 'abstract') {
+                               continue;
+                       }
+                       
+                       // handle simple mapping types
+                       if (isset($name, $this->simpleMapping)) {
+                               $newElement = $element->ownerDocument->createElement($this->simpleMapping[$name]);
+                               DOMUtil::replaceElement($element, $newElement);
+                               
+                               continue;
+                       }
+                       
+                       $attributes = $element->getAttribute('data-attributes');
+                       if (!empty($attributes)) $attributes = @json_decode(base64_decode($attributes), true);
+                       if (!is_array($attributes)) $attributes = [];
+                       
+                       // check for converters
+                       $converter = (isset($converters[$name])) ? $converters[$name] : null;
+                       if ($converter === null) {
+                               $className = 'wcf\\system\\html\\metacode\\converter\\' . $name . 'MetacodeConverter';
+                               if (class_exists($className)) {
+                                       $converter = new $className();
+                                       
+                                       $converters[$name] = $converter;
+                               }
+                       }
+                       
+                       if ($converter === null) {
+                               // no available converter, metacode will be handled during output generation
+                               continue;
+                       }
+                       
+                       /** @var IMetacodeConverter $converter */
+                       if ($converter->validateAttributes($attributes)) {
+                               $newElement = $converter->convert(DOMUtil::childNodesToFragment($element), $attributes);
+                               if (!($newElement instanceof \DOMElement)) {
+                                       throw new SystemException("Expected a valid DOMElement as return value.");
+                               }
+                               
+                               DOMUtil::replaceElement($element, $newElement);
+                       }
+                       else {
+                               // attributes are invalid, remove element from DOM
+                               DOMUtil::removeNode($element, true);
+                       }
+                       
+                       
+                       continue;
+                       $parsedTag = HtmlBBCodeParser::getInstance()->getHtmlOutput($name, $attributes);
+                       
+                       $nodeIdentifier = StringUtil::getRandomID();
+                       $htmlNodeProcessor->addNodeData($this, $nodeIdentifier, [
+                               'parsedTag' => $parsedTag
+                       ]);
+                       
+                       $htmlNodeProcessor->renameTag($metacode, 'wcfNode-' . $nodeIdentifier);
+               }
+       }
+       
+       public function replaceTag(array $data) {
+               return $data['parsedTag'];
+       }
+       
+       protected function getPlaceholderElement() {
+               return new \DOMElement('woltlab-placeholder');
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeWoltlabMetacodeMarker.class.php b/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeWoltlabMetacodeMarker.class.php
new file mode 100644 (file)
index 0000000..e92c4b4
--- /dev/null
@@ -0,0 +1,347 @@
+<?php
+namespace wcf\system\html\input\node;
+use wcf\system\exception\SystemException;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
+use wcf\util\DOMUtil;
+use wcf\util\JSON;
+
+/**
+ * TOOD documentation
+ * @since      2.2
+ */
+class HtmlInputNodeWoltlabMetacodeMarker extends AbstractHtmlNode {
+       public $blockElements = ['code', 'quote'];
+       public $inlineElements = ['b', 'color', 'i', 'tt', 'u'];
+       public $sourceElements = ['code', 'tt'];
+       
+       protected $tagName = 'woltlab-metacode-marker';
+       
+       public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
+               // collect pairs
+               $pairs = $this->buildPairs($elements);
+               
+               // validate pairs and remove items that lack an opening/closing element
+               $pairs = $this->validatePairs($pairs);
+               
+               $pairs = $this->revertMarkerInsideCodeBlocks($pairs);
+               
+               // group pairs by tag name
+               $groups = $this->groupPairsByName($pairs);
+               
+               // convert pairs into HTML or metacode
+               $this->convertGroups($groups);
+       }
+       
+       public function replaceTag(array $data) {
+               return $data['parsedTag'];
+       }
+       
+       /**
+        * @param array $pairs
+        * @return array
+        */
+       protected function revertMarkerInsideCodeBlocks(array $pairs) {
+               $isInsideCode = function(\DOMElement $element) {
+                       $parent = $element;
+                       while ($parent = $parent->parentNode) {
+                               $nodeName = $parent->nodeName;
+                               
+                               if ($nodeName === 'code' || $nodeName === 'kbd') {
+                                       return true;
+                               }
+                               else if ($nodeName === 'woltlab-metacode') {
+                                       $name = $parent->getAttribute('data-name');
+                                       if ($name === 'code' || $name === 'tt') {
+                                               return true;
+                                       }
+                               }
+                       }
+                       
+                       return false;
+               };
+               
+               foreach ($pairs as $uuid => $pair) {
+                       if ($isInsideCode($pair['open']) || $isInsideCode($pair['close'])) {
+                               $this->convertToBBCode($pair);
+                               
+                               unset($pairs[$uuid]);
+                       }
+               }
+               
+               return $pairs;
+       }
+       
+       protected function buildPairs(array $elements) {
+               $pairs = [];
+               /** @var \DOMElement $element */
+               foreach ($elements as $element) {
+                       $attributes = $element->getAttribute('data-attributes');
+                       $name = $element->getAttribute('data-name');
+                       $uuid = $element->getAttribute('data-uuid');
+                       
+                       if (!isset($pairs[$uuid])) {
+                               $pairs[$uuid] = [
+                                       'attributes' => [],
+                                       'close' => null,
+                                       'name' => '',
+                                       'open' => null
+                               ];
+                       }
+                       
+                       if ($name) {
+                               $pairs[$uuid]['attributes'] = $attributes;
+                               $pairs[$uuid]['name'] = $name;
+                               $pairs[$uuid]['open'] = $element;
+                       }
+                       else {
+                               $pairs[$uuid]['close'] = $element;
+                       }
+               }
+               
+               return $pairs;
+       }
+       
+       protected function validatePairs(array $pairs) {
+               foreach ($pairs as $uuid => $data) {
+                       if ($data['close'] === null) {
+                               DOMUtil::removeNode($data['open']);
+                       }
+                       else if ($data['open'] === null) {
+                               DOMUtil::removeNode($data['close']);
+                       }
+                       else {
+                               continue;
+                       }
+                       
+                       unset($pairs[$uuid]);
+               }
+               
+               return $pairs;
+       }
+       
+       protected function groupPairsByName(array $pairs) {
+               $groups = [];
+               foreach ($pairs as $uuid => $data) {
+                       $name = $data['name'];
+                       
+                       if (!isset($groups[$name])) {
+                               $groups[$name] = [];
+                       }
+                       
+                       $groups[$name][] = [
+                               'attributes' => $data['attributes'],
+                               'close' => $data['close'],
+                               'open' => $data['open']
+                       ];
+               }
+               
+               return $groups;
+       }
+       
+       protected function convertGroups(array $groups) {
+               // process source elements first
+               foreach ($this->sourceElements as $name) {
+                       if (in_array($name, $this->blockElements)) {
+                               if (isset($groups[$name])) {
+                                       for ($i = 0, $length = count($groups[$name]); $i < $length; $i++) {
+                                               $data = $groups[$name][$i];
+                                               $this->convertBlockElement($name, $data['open'], $data['close'], $data['attributes']);
+                                       }
+                                       
+                                       unset($groups[$name]);
+                               }
+                       }
+                       else {
+                               if (isset($groups[$name])) {
+                                       for ($i = 0, $length = count($groups[$name]); $i < $length; $i++) {
+                                               $data = $groups[$name][$i];
+                                               $this->convertInlineElement($name, $data['open'], $data['close'], $data['attributes']);
+                                       }
+                                       
+                                       unset($groups[$name]);
+                               }
+                       }
+               }
+               
+               // remaining block elements
+               foreach ($this->blockElements as $name) {
+                       if (isset($groups[$name])) {
+                               for ($i = 0, $length = count($groups[$name]); $i < $length; $i++) {
+                                       $data = $groups[$name][$i];
+                                       $this->convertBlockElement($name, $data['open'], $data['close'], $data['attributes']);
+                               }
+                               
+                               unset($groups[$name]);
+                       }
+               }
+               
+               // treat remaining elements as inline elements
+               foreach ($groups as $name => $pairs) {
+                       for ($i = 0, $length = count($pairs); $i < $length; $i++) {
+                               $data = $pairs[$i];
+                               $this->convertInlineElement($name, $data['open'], $data['close'], $data['attributes']);
+                       }
+               }
+       }
+       
+       /**
+        * @param string $name
+        * @param \DOMElement $start
+        * @param \DOMElement $end
+        * @param string $attributes
+        */
+       protected function convertBlockElement($name, $start, $end, $attributes) {
+               $commonAncestor = DOMUtil::getCommonAncestor($start, $end);
+               $lastElement = DOMUtil::splitParentsUntil($end, $commonAncestor, false);
+               
+               $container = $start->ownerDocument->createElement('woltlab-metacode');
+               $container->setAttribute('data-name', $name);
+               $container->setAttribute('data-attributes', $attributes);
+               
+               DOMUtil::insertAfter($container, $start);
+               DOMUtil::removeNode($start);
+               
+               DOMUtil::moveNodesInto($container, $lastElement, $commonAncestor);
+               
+               DOMUtil::removeNode($end);
+       }
+       
+       /**
+        * @param string $name
+        * @param \DOMElement $start
+        * @param \DOMElement $end
+        * @param string $attributes
+        */
+       protected function convertInlineElement($name, $start, $end, $attributes) {
+               if ($start->parentNode === $end->parentNode) {
+                       $this->wrapContent($name, $attributes, $start, $end);
+                       
+                       DOMUtil::removeNode($start);
+                       DOMUtil::removeNode($end);
+               }
+               else {
+                       $commonAncestor = DOMUtil::getCommonAncestor($start, $end);
+                       $endAncestor = DOMUtil::getParentBefore($end, $commonAncestor);
+                       
+                       $element = $this->wrapContent($name, $attributes, $start, null);
+                       DOMUtil::removeNode($start);
+                       
+                       $element = DOMUtil::getParentBefore($element, $commonAncestor);
+                       while ($element = $element->nextSibling) {
+                               if ($element->nodeType === XML_TEXT_NODE) {
+                                       // ignore text nodes between tags
+                                       continue;
+                               }
+                               
+                               if ($element !== $endAncestor) {
+                                       if ($this->isBlockElement($element)) {
+                                               $this->wrapContent($name, $attributes, $element->firstChild, null);
+                                       }
+                                       else {
+                                               $this->wrapContent($name, $attributes, $element, null);
+                                       }
+                               }
+                               else {
+                                       $this->wrapContent($name, $attributes, null, $end);
+                                       
+                                       DOMUtil::removeNode($end);
+                                       break;
+                               }
+                       }
+               }
+       }
+       
+       /**
+        * @param string $name
+        * @param string $attributes
+        * @param \DOMElement|null $startNode
+        * @param \DOMElement|null $endNode
+        * @return      \DOMElement
+        */
+       protected function wrapContent($name, $attributes, $startNode, $endNode) {
+               $element = ($startNode) ? $startNode->ownerDocument->createElement('woltlab-metacode') : $endNode->ownerDocument->createElement('woltlab-metacode');
+               $element->setAttribute('data-name', $name);
+               $element->setAttribute('data-attributes', $attributes);
+               
+               if ($startNode) {
+                       DOMUtil::insertBefore($element, $startNode);
+                       
+                       while ($sibling = $element->nextSibling) {
+                               $element->appendChild($sibling);
+                               
+                               if ($sibling === $endNode) {
+                                       break;
+                               }
+                       }
+               }
+               else {
+                       DOMUtil::insertAfter($element, $endNode);
+                       
+                       while ($sibling = $element->previousSibling) {
+                               DOMUtil::prepend($sibling, $element);
+                               
+                               if ($sibling === $startNode) {
+                                       break;
+                               }
+                       }
+               }
+               
+               return $element;
+       }
+       
+       /**
+        * Returns true if provided node is a block element.
+        * 
+        * @param       \DOMNode        $node           node
+        * @return      boolean         true for certain block elements
+        */
+       protected function isBlockElement(\DOMNode $node) {
+               switch ($node->nodeName) {
+                       case 'blockquote':
+                       case 'code':
+                       case 'div':
+                       case 'p':
+                               return true;
+               }
+               
+               return false;
+       }
+       
+       protected function convertToBBCode(array $pair) {
+               /** @var \DOMElement $start */
+               $start = $pair['open'];
+               /** @var \DOMElement $end */
+               $end = $pair['close'];
+               
+               $attributes = '';
+               if (!empty($pair['attributes'])) {
+                       $pair['attributes'] = base64_decode($pair['attributes'], true);
+                       if ($pair['attributes'] !== false) {
+                               try {
+                                       $pair['attributes'] = JSON::decode($pair['attributes']);
+                               }
+                               catch (SystemException $e) {
+                                       $pair['attributes'] = [];
+                               }
+                               
+                               if (!empty($pair['attributes'])) {
+                                       foreach ($pair['attributes'] as &$attribute) {
+                                               $attribute = "'" . addcslashes($attribute, "'") . "'";
+                                       }
+                                       unset($attribute);
+                                       
+                                       $attributes = '=' . implode(",", $attributes);
+                               }
+                       }
+               }
+               
+               $textNode = $start->ownerDocument->createTextNode('[' . $pair['name'] . $attributes . ']');
+               DOMUtil::insertBefore($textNode, $start);
+               DOMUtil::removeNode($start);
+               
+               $textNode = $end->ownerDocument->createTextNode('[/' . $pair['name'] . ']');
+               DOMUtil::insertBefore($textNode, $end);
+               DOMUtil::removeNode($end);
+       }
+}
index 37cc2bc4b8b6642c3d1dc7eb34720709c3379159..a23f47f166f65ae1914c38f8e4a8eaccbc8ddbbb 100644 (file)
@@ -1,10 +1,9 @@
 <?php
 namespace wcf\system\html\input\node;
+use wcf\system\html\node\IHtmlNode;
 
 /**
  * TOOD documentation
  * @since      2.2
  */
-interface IHtmlInputNode {
-       public function process(HtmlInputNodeProcessor $htmlInputNodeProcessor);
-}
+interface IHtmlInputNode extends IHtmlNode {}
diff --git a/wcfsetup/install/files/lib/system/html/metacode/converter/AbstractMetacodeConverter.class.php b/wcfsetup/install/files/lib/system/html/metacode/converter/AbstractMetacodeConverter.class.php
new file mode 100644 (file)
index 0000000..fabe361
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+namespace wcf\system\html\metacode\converter;
+
+abstract class AbstractMetacodeConverter implements IMetacodeConverter {
+       /**
+        * @inheritDoc
+        */
+       public function validateAttributes(array $attributes) {
+               return true;
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/html/metacode/converter/ColorMetacodeConverter.class.php b/wcfsetup/install/files/lib/system/html/metacode/converter/ColorMetacodeConverter.class.php
new file mode 100644 (file)
index 0000000..3ea3b0e
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+namespace wcf\system\html\metacode\converter;
+
+class ColorMetacodeConverter extends AbstractMetacodeConverter {
+       /**
+        * @inheritDoc
+        */
+       public function convert(\DOMDocumentFragment $fragment, array $attributes) {
+               $woltlabColor = $fragment->ownerDocument->createElement('woltlab-color');
+               $woltlabColor->setAttribute('class', 'woltlab-color-' . strtoupper(substr($attributes[0], 1)));
+               $woltlabColor->appendChild($fragment);
+               
+               return $woltlabColor;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function validateAttributes(array $attributes) {
+               if (count($attributes) !== 1) {
+                       return false;
+               }
+               
+               // validates if code is a valid (short) HEX color code
+               if (!preg_match('~^#[A-F0-9]{3}(?:[A-F0-9]{3})?$~i', $attributes[0])) {
+                       return false;
+               }
+               
+               return true;
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/html/metacode/converter/IMetacodeConverter.class.php b/wcfsetup/install/files/lib/system/html/metacode/converter/IMetacodeConverter.class.php
new file mode 100644 (file)
index 0000000..23b34d1
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+namespace wcf\system\html\metacode\converter;
+
+interface IMetacodeConverter {
+       /**
+        * Converts a known metacode into the HTML representation normally used by the WYSIWYG
+        * editor. This process is designed to turn simple bbcodes into their HTML counterpart
+        * without forcing the bbcode to be evaluated each time.
+        * 
+        * The fragment must be inserted into your returned DOM element.
+        * 
+        * @param       \DOMDocumentFragment    $fragment       fragment containing all child nodes, must be appended to returned element
+        * @param       array                   $attributes     list of attributes
+        * @return      \DOMElement             new DOM element
+        */
+       public function convert(\DOMDocumentFragment $fragment, array $attributes);
+       
+       /**
+        * Validates attributes before any DOM modification occurs.
+        * 
+        * @param       array           $attributes     list of attributes
+        * @return      boolean         false if attributes did not match the converter's expectation
+        */
+       public function validateAttributes(array $attributes);
+}
diff --git a/wcfsetup/install/files/lib/system/html/metacode/converter/QuoteMetacodeConverter.class.php b/wcfsetup/install/files/lib/system/html/metacode/converter/QuoteMetacodeConverter.class.php
new file mode 100644 (file)
index 0000000..3e291cf
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+namespace wcf\system\html\metacode\converter;
+
+class QuoteMetacodeConverter extends AbstractMetacodeConverter {
+       /**
+        * @inheritDoc
+        */
+       public function convert(\DOMDocumentFragment $fragment, array $attributes) {
+               $element = $fragment->ownerDocument->createElement('blockquote');
+               $element->setAttribute('class', 'quoteBox');
+               $element->setAttribute('data-quote-title', (isset($attributes[0])) ? $attributes[0] : '');
+               $element->setAttribute('data-quote-url', (isset($attributes[1])) ? $attributes[1] : '');
+               $element->appendChild($fragment);
+               
+               return $element;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function validateAttributes(array $attributes) {
+               // 0, 1 or 2 attributes
+               return (count($attributes) <= 2);
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/html/node/AbstractHtmlNode.class.php b/wcfsetup/install/files/lib/system/html/node/AbstractHtmlNode.class.php
new file mode 100644 (file)
index 0000000..1367ad0
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+namespace wcf\system\html\node;
+
+abstract class AbstractHtmlNode implements IHtmlNode {
+       protected $tagName = '';
+       
+       public function getTagName() {
+               return $this->tagName;
+       }
+       
+       public function replaceTag(array $data) {
+               throw new \BadMethodCallException("Method replaceTag() is not supported by ".get_class($this));
+       }
+}
index 4460d06df061f7e93a3da9404465d8d61965cd0f..0e37b2ad9efc380f9cc683487f219a6aa518f32b 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 namespace wcf\system\html\node;
+use wcf\system\exception\SystemException;
 
 /**
  * TOOD documentation
@@ -11,6 +12,8 @@ class HtmlNodeProcessor {
         */
        protected $document;
        
+       protected $nodeData = [];
+       
        public function load($html) {
                $this->document = new \DOMDocument();
                
@@ -21,6 +24,8 @@ class HtmlNodeProcessor {
                // provide a proper way to add custom HTML elements (even though explicitly allowed
                // in HTML5) and the input HTML has already been sanitized by HTMLPurifier
                @$this->document->loadHTML($html);
+               
+               $this->nodeData = [];
        }
        
        public function getHtml() {
@@ -32,6 +37,18 @@ class HtmlNodeProcessor {
                
                $html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');
                
+               /** @var IHtmlNode $obj */
+               foreach ($this->nodeData as $data) {
+                       $obj = $data['object'];
+                       $string = $obj->replaceTag($data['data']);
+                       $html = preg_replace_callback('~<wcfNode-' . $data['identifier'] . '>(?P<content>.*)</wcfNode-' . $data['identifier'] . '>~', function($matches) use ($string) {
+                               $string = str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
+                               
+                               return $string;
+                       }, $html);
+                       
+               }
+               
                return $html;
        }
        
@@ -58,4 +75,28 @@ class HtmlNodeProcessor {
                
                $element->parentNode->removeChild($element);
        }
+       
+       public function addNodeData(IHtmlNode $htmlNode, $nodeIdentifier, array $data) {
+               $this->nodeData[] = [
+                       'data' => $data,
+                       'identifier' => $nodeIdentifier,
+                       'object' => $htmlNode
+               ];
+       }
+       
+       protected function invokeHtmlNode(IHtmlNode $htmlNode) {
+               $tagName = $htmlNode->getTagName();
+               if (empty($tagName)) {
+                       throw new SystemException("Missing tag name for " . get_class($htmlNode));
+               }
+               
+               $elements = [];
+               foreach ($this->getDocument()->getElementsByTagName($tagName) as $element) {
+                       $elements[] = $element;
+               }
+               
+               if (!empty($elements)) {
+                       $htmlNode->process($elements, $this);
+               }
+       }
 }
diff --git a/wcfsetup/install/files/lib/system/html/node/IHtmlNode.class.php b/wcfsetup/install/files/lib/system/html/node/IHtmlNode.class.php
new file mode 100644 (file)
index 0000000..2ee2be1
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+namespace wcf\system\html\node;
+
+interface IHtmlNode {
+       public function getTagName();
+       
+       /**
+        * @param \DOMElement[] $elements
+        * @param HtmlNodeProcessor $htmlNodeProcessor
+        * @return mixed
+        */
+       public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor);
+       
+       public function replaceTag(array $data);
+}
index 0d4df4cfb7c97f67591f232886fbefd725fe6826..91b7139e9dfe80ce2e080bee349cbf086c877401 100644 (file)
@@ -3,53 +3,15 @@ namespace wcf\system\html\output;
 use wcf\system\html\node\HtmlNodeProcessor;
 use wcf\system\html\output\node\HtmlOutputNodeBlockquote;
 use wcf\system\html\output\node\HtmlOutputNodeWoltlabMention;
-use wcf\system\html\output\node\IHtmlOutputNode;
 
 /**
  * TOOD documentation
  * @since      2.2
  */
 class HtmlOutputNodeProcessor extends HtmlNodeProcessor {
-       protected $nodeData = [];
-       
-       public function load($html) {
-               parent::load($html);
-               
-               $this->nodeData = [];
-       }
-       
        public function process() {
                // TODO: this should be dynamic to some extent
-               $quoteNode = new HtmlOutputNodeBlockquote();
-               $quoteNode->process($this);
-               
-               $woltlabMentionNode = new HtmlOutputNodeWoltlabMention();
-               $woltlabMentionNode->process($this);
-       }
-       
-       public function getHtml() {
-               $html = parent::getHtml();
-               
-               /** @var IHtmlOutputNode $obj */
-               foreach ($this->nodeData as $data) {
-                       $obj = $data['object'];
-                       $string = $obj->replaceTag($data['data']);
-                       $html = preg_replace_callback('~<wcfNode-' . $data['identifier'] . '>(?P<content>.*)</wcfNode-' . $data['identifier'] . '>~', function($matches) use ($string) {
-                               $string = str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
-                               
-                               return $string;
-                       }, $html);
-                       
-               }
-               
-               return $html;
-       }
-       
-       public function addNodeData(IHtmlOutputNode $htmlOutputNode, $nodeIdentifier, array $data) {
-               $this->nodeData[] = [
-                       'data' => $data,
-                       'identifier' => $nodeIdentifier,
-                       'object' => $htmlOutputNode
-               ];
+               $this->invokeHtmlNode(new HtmlOutputNodeBlockquote());
+               $this->invokeHtmlNode(new HtmlOutputNodeWoltlabMention());
        }
 }
index b5cfc5361aa08f9313fa192c3b2477789683bcd4..c179eb7e33f1c6ca658f86ff0f28cec2e02c2292 100644 (file)
@@ -1,7 +1,8 @@
 <?php
 namespace wcf\system\html\output\node;
 use wcf\system\application\ApplicationHandler;
-use wcf\system\html\output\HtmlOutputNodeProcessor;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
 use wcf\system\request\RouteHandler;
 use wcf\system\WCF;
 use wcf\util\StringUtil;
@@ -10,24 +11,25 @@ use wcf\util\StringUtil;
  * TOOD documentation
  * @since      2.2
  */
-class HtmlOutputNodeBlockquote implements IHtmlOutputNode {
-       public function process(HtmlOutputNodeProcessor $htmlOutputNodeProcessor) {
-               $elements = $htmlOutputNodeProcessor->getDocument()->getElementsByTagName('blockquote');
-               while ($elements->length) {
-                       /** @var \DOMElement $blockquote */
-                       $blockquote = $elements->item(0);
-                       
-                       if ($blockquote->getAttribute('class') === 'quoteBox') {
+class HtmlOutputNodeBlockquote extends AbstractHtmlNode {
+       protected $tagName = 'blockquote';
+       
+       /**
+        * @inheritDoc
+        */
+       public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
+               foreach ($elements as $element) {
+                       if ($element->getAttribute('class') === 'quoteBox') {
                                $nodeIdentifier = StringUtil::getRandomID();
-                               $htmlOutputNodeProcessor->addNodeData($this, $nodeIdentifier, [
-                                       'title' => ($blockquote->hasAttribute('data-quote-title')) ? $blockquote->getAttribute('data-quote-title') : '',
-                                       'url' => ($blockquote->hasAttribute('data-quote-url')) ? $blockquote->getAttribute('data-quote-url') : ''
+                               $htmlNodeProcessor->addNodeData($this, $nodeIdentifier, [
+                                       'title' => ($element->hasAttribute('data-quote-title')) ? $element->getAttribute('data-quote-title') : '',
+                                       'url' => ($element->hasAttribute('data-quote-url')) ? $element->getAttribute('data-quote-url') : ''
                                ]);
                                
-                               $htmlOutputNodeProcessor->renameTag($blockquote, 'wcfNode-' . $nodeIdentifier);
+                               $htmlNodeProcessor->renameTag($element, 'wcfNode-' . $nodeIdentifier);
                        }
                        else {
-                               $htmlOutputNodeProcessor->unwrapContent($blockquote);
+                               $htmlNodeProcessor->unwrapContent($element);
                        }
                }
        }
index 96eb48763c97c949905ca2d7ec065b39a9470d2f..14355c3f024aa9a6510cda7834852465c6e54636 100644 (file)
@@ -2,45 +2,48 @@
 namespace wcf\system\html\output\node;
 use wcf\data\user\UserProfile;
 use wcf\system\cache\runtime\UserProfileRuntimeCache;
-use wcf\system\html\output\HtmlOutputNodeProcessor;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
 use wcf\system\WCF;
+use wcf\util\DOMUtil;
 use wcf\util\StringUtil;
 
 /**
  * TOOD documentation
  * @since      2.2
  */
-class HtmlOutputNodeWoltlabMention implements IHtmlOutputNode {
+class HtmlOutputNodeWoltlabMention extends AbstractHtmlNode {
+       protected $tagName = 'woltlab-mention';
+       
        /**
         * @var UserProfile[]
         */
        protected $userProfiles;
        
-       public function process(HtmlOutputNodeProcessor $htmlOutputNodeProcessor) {
+       /**
+        * @inheritDoc
+        */
+       public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
                $this->userProfiles = [];
                
                $userIds = [];
-               $elements = $htmlOutputNodeProcessor->getDocument()->getElementsByTagName('woltlab-mention');
-               while ($elements->length) {
-                       /** @var \DOMElement $mention */
-                       $mention = $elements->item(0);
-                       
-                       $userId = ($mention->hasAttribute('data-user-id')) ? intval($mention->getAttribute('data-user-id')) : 0;
-                       $username = ($mention->hasAttribute('data-username')) ? StringUtil::trim($mention->getAttribute('data-username')) : '';
+               foreach ($elements as $element) {
+                       $userId = ($element->hasAttribute('data-user-id')) ? intval($element->getAttribute('data-user-id')) : 0;
+                       $username = ($element->hasAttribute('data-username')) ? StringUtil::trim($element->getAttribute('data-username')) : '';
                        
                        if ($userId === 0 || $username === '') {
-                               $mention->parentNode->removeChild($mention);
+                               DOMUtil::removeNode($element);
                                continue;
                        }
                        
                        $userIds[] = $userId;
                        $nodeIdentifier = StringUtil::getRandomID();
-                       $htmlOutputNodeProcessor->addNodeData($this, $nodeIdentifier, [
+                       $htmlNodeProcessor->addNodeData($this, $nodeIdentifier, [
                                'userId' => $userId,
                                'username' => $username
                        ]);
                        
-                       $htmlOutputNodeProcessor->renameTag($mention, 'wcfNode-' . $nodeIdentifier);
+                       $htmlNodeProcessor->renameTag($element, 'wcfNode-' . $nodeIdentifier);
                }
                
                if (!empty($userIds)) {
index b3bd23c7a6d520cbacea2fd20fece364af3cf8f7..da49187406d45a748a81fd7d8bc4c66b7f24af7d 100644 (file)
@@ -1,13 +1,9 @@
 <?php
 namespace wcf\system\html\output\node;
-use wcf\system\html\output\HtmlOutputNodeProcessor;
+use wcf\system\html\node\IHtmlNode;
 
 /**
  * TOOD documentation
  * @since      2.2
  */
-interface IHtmlOutputNode {
-       public function process(HtmlOutputNodeProcessor $htmlOutputNodeProcessor);
-       
-       public function replaceTag(array $data);
-}
+interface IHtmlOutputNode extends IHtmlNode {}
diff --git a/wcfsetup/install/files/lib/util/DOMUtil.class.php b/wcfsetup/install/files/lib/util/DOMUtil.class.php
new file mode 100644 (file)
index 0000000..f30db6d
--- /dev/null
@@ -0,0 +1,422 @@
+<?php
+namespace wcf\util;
+use wcf\system\exception\SystemException;
+
+/**
+ * Provides helper methods to work with PHP's DOM implementation.
+ * 
+ * @author      Alexander Ebert
+ * @copyright   2001-2016 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package     com.woltlab.wcf
+ * @subpackage  util
+ * @category    Community Framework
+ */
+final class DOMUtil {
+       /**
+        * Moves all child nodes from given element into a document fragment.
+        * 
+        * @param       \DOMElement     $element        element
+        * @return      \DOMDocumentFragment            document fragment containing all child nodes from `$element`
+        */
+       public static function childNodesToFragment(\DOMElement $element) {
+               $fragment = $element->ownerDocument->createDocumentFragment();
+               
+               while ($element->hasChildNodes()) {
+                       $fragment->appendChild($element->childNodes->item(0));
+               }
+               
+               return $fragment;
+       }
+       
+       /**
+        * Returns true if `$ancestor` contains the node `$node`.
+        * 
+        * @param       \DOMNode        $ancestor       ancestor node
+        * @param       \DOMNode        $node           node
+        * @return      boolean         true if `$ancestor` contains the node `$node`
+        */
+       public static function contains(\DOMNode $ancestor, \DOMNode $node) {
+               // nodes cannot contain themselves
+               if ($ancestor === $node) {
+                       return false;
+               }
+               
+               // text nodes cannot contain any other nodes
+               if ($ancestor->nodeType === XML_TEXT_NODE) {
+                       return false;
+               }
+               
+               $parent = $node;
+               while ($parent = $parent->parentNode) {
+                       if ($parent === $ancestor) {
+                               return true;
+                       }
+               }
+               
+               return false;
+       }
+       
+       /**
+        * Returns the common ancestor of both nodes.
+        * 
+        * @param       \DOMNode                $node1          first node
+        * @param       \DOMNode                $node2          second node
+        * @return      \DOMElement|null        common ancestor or null
+        */
+       public static function getCommonAncestor(\DOMNode $node1, \DOMNode $node2) {
+               // abort if both elements share a common element or are both direct descendants
+               // of the same document
+               if ($node1->parentNode === $node2->parentNode) {
+                       return $node1->parentNode;
+               }
+               
+               // collect the list of all direct ancestors of `$node1`
+               $parents = self::getParents($node1);
+               
+               // compare each ancestor of `$node2` to the known list of parents of `$node1`
+               $parent = $node2;
+               while ($parent = $parent->parentNode) {
+                       // requires strict type check
+                       if (in_array($parent, $parents, true)) {
+                               return $parent;
+                       }
+               }
+               
+               return null;
+       }
+       
+       /**
+        * Returns the immediate parent element before provided ancenstor element. Returns null if
+        * the ancestor element is the direct parent of provided node.
+        * 
+        * @param       \DOMNode                $node           node
+        * @param       \DOMElement             $ancestor       ancestor node
+        * @return      \DOMElement|null        immediate parent element before ancestor element
+        */
+       public static function getParentBefore(\DOMNode $node, \DOMElement $ancestor) {
+               if ($node->parentNode === $ancestor) {
+                       return null;
+               }
+               
+               $parents = self::getParents($node);
+               for ($i = count($parents) - 1; $i >= 0; $i--) {
+                       if ($parents[$i] === $ancestor) {
+                               return $parents[$i - 1];
+                       }
+               }
+               
+               throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
+       }
+       
+       /**
+        * Returns the parent node of given node.
+        *
+        * @param       \DOMNode        $node           node
+        * @return      \DOMNode        parent node, can be `\DOMElement` or `\DOMDocument`
+        */
+       public static function getParentNode(\DOMNode $node) {
+               return ($node->parentNode) ?: $node->ownerDocument;
+       }
+       
+       /**
+        * Returns all ancestors nodes for given node.
+        * 
+        * @param       \DOMNode        $node           node
+        * @param       boolean         $reverseOrder   reversing the order causes the most top ancestor to appear first
+        * @return      \DOMElement[]   list of ancestor nodes
+        */
+       public static function getParents(\DOMNode $node, $reverseOrder = false) {
+               $parents = [];
+               
+               $parent = $node;
+               while ($parent = $parent->parentNode) {
+                       $parents[] = $parent;
+               }
+               
+               return ($reverseOrder) ? array_reverse($parents) : $parents;
+       }
+       
+       /**
+        * Determines the relative position of two nodes to each other.
+        * 
+        * @param       \DOMNode        $node1          first node
+        * @param       \DOMNode        $node2          second node
+        * @return      string
+        */
+       public static function getRelativePosition(\DOMNode $node1, \DOMNode $node2) {
+               if ($node1->ownerDocument !== $node2->ownerDocument) {
+                       throw new \InvalidArgumentException("Both nodes must be contained in the same DOM document.");
+               }
+               
+               $nodeList1 = self::getParents($node1, true);
+               $nodeList1[] = $node1;
+               
+               $nodeList2 = self::getParents($node2, true);
+               $nodeList2[] = $node2;
+               
+               $commonAncestor = null;
+               $i = 0;
+               while ($nodeList1[$i] === $nodeList2[$i]) {
+                       $i++;
+               }
+               
+               // check if parent of node 2 appears before parent of node 1
+               $previousSibling = $nodeList1[$i];
+               while ($previousSibling = $previousSibling->previousSibling) {
+                       if ($previousSibling === $nodeList2[$i]) {
+                               return 'before';
+                       }
+               }
+               
+               $nextSibling = $nodeList1[$i];
+               while ($nextSibling = $nextSibling->nextSibling) {
+                       if ($nextSibling === $nodeList2[$i]) {
+                               return 'after';
+                       }
+               }
+               
+               throw new SystemException("Unable to determine relative node position.");
+       }
+       
+       /**
+        * Inserts given DOM node after the reference node.
+        * 
+        * @param       \DOMNode        $node           node
+        * @param       \DOMNode        $refNode        reference node
+        */
+       public static function insertAfter(\DOMNode $node, \DOMNode $refNode) {
+               if ($refNode->nextSibling) {
+                       self::insertBefore($node, $refNode->nextSibling);
+               }
+               else {
+                       self::getParentNode($refNode)->appendChild($node);
+               }
+       }
+       
+       /**
+        * Inserts given node before the reference node.
+        * 
+        * @param       \DOMNode        $node           node
+        * @param       \DOMNode        $refNode        reference node
+        */
+       public static function insertBefore(\DOMNode $node, \DOMNode $refNode) {
+               self::getParentNode($refNode)->insertBefore($node, $refNode);
+       }
+       
+       /**
+        * Returns true if given node is the first node of its given ancestor.
+        * 
+        * @param       \DOMNode        $node           node
+        * @param       \DOMElement     $ancestor       ancestor element
+        * @return      boolean         true if `$node` is the first node of its given ancestor
+        */
+       public static function isFirstNode(\DOMNode $node, \DOMElement $ancestor) {
+               if ($node->previousSibling === null) {
+                       if ($node->previousSibling === null) {
+                               throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
+                       }
+                       else if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
+                               return true;
+                       }
+                       else {
+                               return self::isFirstNode($node->parentNode, $ancestor);
+                       }
+               }
+               else if ($node->parentNode->nodeName === 'body') {
+                       return true;
+               }
+               
+               return false;
+       }
+       
+       /**
+        * Returns true if given node is the last node of its given ancestor.
+        * 
+        * @param       \DOMNode        $node           node
+        * @param       \DOMElement     $ancestor       ancestor element
+        * @return      boolean         true if `$node` is the last node of its given ancestor
+        */
+       public static function isLastNode(\DOMNode $node, \DOMElement $ancestor) {
+               if ($node->nextSibling === null) {
+                       if ($node->parentNode === null) {
+                               throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
+                       }
+                       else if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
+                               return true;
+                       }
+                       else {
+                               return self::isLastNode($node->parentNode, $ancestor);
+                       }
+               }
+               else if ($node->parentNode->nodeName === 'body') {
+                       return true;
+               }
+               
+               return false;
+       }
+       
+       /**
+        * Moves all nodes into `$container` until it reaches `$lastElement`. The direction
+        * in which nodes will be considered for moving is determined by the logical position
+        * of `$lastElement`.
+        * 
+        * @param       \DOMElement     $container              destination element
+        * @param       \DOMElement     $lastElement            last element to move
+        * @param       \DOMElement     $commonAncestor         common ancestor of `$container` and `$lastElement`
+        */
+       public static function moveNodesInto(\DOMElement $container, \DOMElement $lastElement, \DOMElement $commonAncestor) {
+               if (!self::contains($commonAncestor, $container)) {
+                       throw new \InvalidArgumentException("The container element must be a child of the common ancestor element.");
+               }
+               else if ($lastElement->parentNode !== $commonAncestor) {
+                       throw new \InvalidArgumentException("The last element must be a direct child of the common ancestor element.");
+               }
+               
+               $relativePosition = self::getRelativePosition($container, $lastElement);
+               
+               // move everything that is logically after `$container` but within
+               // `$commonAncestor` into `$container` until `$lastElement` has been moved
+               $element = $container;
+               do {
+                       if ($relativePosition === 'before') {
+                               while ($sibling = $element->previousSibling) {
+                                       self::prepend($sibling, $container);
+                                       if ($sibling === $lastElement) {
+                                               return;
+                                       }
+                               }
+                       }
+                       else {
+                               while ($sibling = $element->nextSibling) {
+                                       $container->appendChild($sibling);
+                                       if ($sibling === $lastElement) {
+                                               return;
+                                       }
+                               }
+                       }
+                       
+                       $element = $element->parentNode;
+               }
+               while ($element !== $commonAncestor);
+       }
+       
+       /**
+        * Prepends a node to provided element.
+        * 
+        * @param       \DOMNode        $node           node
+        * @param       \DOMElement     $element        target element
+        */
+       public static function prepend(\DOMNode $node, \DOMElement $element) {
+               if ($element->firstChild === null) {
+                       $element->appendChild($node);
+               }
+               else {
+                       $element->insertBefore($node, $element->firstChild);
+               }
+       }
+       
+       /**
+        * Removes a node, optionally preserves the child nodes if `$node` is an element.
+        * 
+        * @param       \DOMNode        $node                   target node
+        * @param       boolean         $preserveChildNodes     preserve child nodes, only supported for elements
+        */
+       public static function removeNode(\DOMNode $node, $preserveChildNodes = false) {
+               if ($preserveChildNodes) {
+                       if (!($node instanceof \DOMElement)) {
+                               throw new \InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
+                       }
+                       
+                       while ($node->hasChildNodes()) {
+                               self::insertBefore($node->childNodes->item(0), $node);
+                       }
+               }
+               
+               self::getParentNode($node)->removeChild($node);
+       }
+       
+       /**
+        * Replaces a DOM element with another, preserving all child nodes by default.
+        * 
+        * @param       \DOMElement     $oldElement             old element
+        * @param       \DOMElement     $newElement             new element
+        * @param       boolean         $preserveChildNodes     true if child nodes should be moved, otherwise they'll be implicitly removed
+        */
+       public static function replaceElement(\DOMElement $oldElement, \DOMElement $newElement, $preserveChildNodes = true) {
+               self::insertBefore($newElement, $oldElement);
+               
+               // move all child nodes
+               if ($preserveChildNodes) {
+                       while ($oldElement->hasChildNodes()) {
+                               $newElement->appendChild($oldElement->childNodes->item(0));
+                       }
+               }
+               
+               // remove old element
+               self::getParentNode($oldElement)->removeChild($oldElement);
+       }
+       
+       /**
+        * Splits all parent nodes until `$ancestor` and moved other nodes after/before
+        * (determined by `$splitBefore`) into the newly created nodes. This allows
+        * extraction of DOM parts while preserving nesting for both the extracted nodes
+        * and the remaining siblings.
+        * 
+        * @param       \DOMNode        $node           reference node
+        * @param       \DOMElement     $ancestor       ancestor element that should not be split
+        * @param       boolean         $splitBefore    true if nodes before `$node` should be moved into a new node, false to split nodes after `$node`
+        * @return      \DOMElement     parent node containing `$node`, direct child of `$ancestor`
+        */
+       public static function splitParentsUntil(\DOMNode $node, \DOMElement $ancestor, $splitBefore = true) {
+               if (!self::contains($ancestor, $node)) {
+                       throw new \InvalidArgumentException("Node is not contained in ancestor node.");
+               }
+               
+               // clone the parent node right "below" `$ancestor`
+               $cloneNode = self::getParentBefore($node, $ancestor);
+               
+               if ($splitBefore) {
+                       if (self::isFirstNode($node, $cloneNode)) {
+                               // target node is at the very start, we can safely move the
+                               // entire parent node around
+                               return $cloneNode;
+                       }
+                       
+                       $currentNode = $node;
+                       while (($parent = $currentNode->parentNode) !== $ancestor) {
+                               $newNode = $parent->cloneNode();
+                               self::insertBefore($newNode, $parent);
+                               
+                               while ($currentNode->previousSibling) {
+                                       $newNode->appendChild($currentNode->previousSibling);
+                               }
+                               
+                               $currentNode = $parent;
+                       }
+               }
+               else {
+                       if (self::isLastNode($node, $cloneNode)) {
+                               // target node is at the very end, we can safely move the
+                               // entire parent node around
+                               return $cloneNode;
+                       }
+                       
+                       $currentNode = $node;
+                       while (($parent = $currentNode->parentNode) !== $ancestor) {
+                               $newNode = $parent->cloneNode();
+                               self::insertAfter($newNode, $parent);
+                               
+                               while ($currentNode->nextSibling) {
+                                       $newNode->appendChild($currentNode->nextSibling);
+                               }
+                               
+                               $currentNode = $parent;
+                       }
+               }
+               
+               return self::getParentBefore($node, $ancestor);
+       }
+       
+       private function __construct() { }
+}