DOMDocument Extensions

My DOMDocument extensions, which started as SmartDocument and later went into use as WPGMZA\DOMDocument, is an extension of PHP’s native DOMDocument which includes many jQuery-like functions.

The main aim of this library is to facilitate easier form processing, especially for smaller projects where a full blown framework is undesirable.

<?php

namespace WPGMZA;

if(!defined('ABSPATH'))
	return;

require_once(plugin_dir_path(__FILE__) . 'class.dom-element.php');

class DOMDocument extends \DOMDocument
{
	private $src_file;
	
	/**
	 * Constructor
	 * @see http://php.net/manual/en/class.domdocument.php
	 */
	public function __construct($version='1.0', $encoding='UTF-8')
	{
		\DOMDocument::__construct($version, $encoding);
		
		$this->registerNodeClass('DOMElement', 'WPGMZA\DOMElement');
		$this->onReady();
	}
	
	public static function convertUTF8ToHTMLEntities($html)
	{
		if(function_exists('mb_convert_encoding'))
			return mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
		else
		{
			trigger_error('Using fallback UTF to HTML entity conversion', E_USER_NOTICE);
			return htmlspecialchars_decode(utf8_decode(htmlentities($html, ENT_COMPAT, 'utf-8', false)));
		}
	}
	
	/**
	 * Fired after construction when the Document is initialized
	 * @return void
	 */
	protected function onReady()
	{
		
	}
	
	/**
	 * Fired after content has been loaded
	 * @return void
	 */
	protected function onLoaded()
	{
		
	}
	
	/**
	 * Loads the specified file and evaulates nodes
	 * @see http://php.net/manual/en/domdocument.load.php
	 * @param string $src The file you want to load
	 * @param int $options See http://php.net/manual/en/domdocument.load.php
	 * @return boolean TRUE on success, FALSE otherwise
	 */
	public function load($src, $options=null)
	{
		if(!is_string($src))
			throw new \Exception('Input must be a string');
		
		$result = \DOMDocument::load($src, $options);
		$this->src_file = $src;
		
		$this->onEvaluateNodes();
		$this->onLoaded();
		
		return $result;
	}
	
	private function translateLineNumber($htmlLineNumber, $src)
	{
		
	}
	
	public function onError($severity, $message, $file, $unused)
	{
		if(!preg_match('/DOMDocument::loadHTML.+line: (\d+)/', $message, $m))
		{
			trigger_error($message, E_USER_WARNING);
			return;
		}
		
		$htmlLineNumber	= $m[1];
		$lines			= file($this->src_file);
		
		$totalPhpLines	= count($lines);
		$lineCounter	= 1;
		
		$allowShortTags	= ini_get('short_open_tag') == "1";
		$regexOpenTag	= ($allowShortTags ? '/<\?(php)?/' : '/<\?php/');
		$regexCloseTag	= "/\?>/";
		
		$inPhp			= false;
		
		for($phpLineNumber = 1; $phpLineNumber <= $totalPhpLines; $phpLineNumber++)
		{
			if($lineCounter == $htmlLineNumber)
			{
				$message = preg_replace(
					array('/loadHTML/', '/line: \d+/'), 
					array('loadPHPFile', "line: $phpLineNumber"), 
					$message
				);
				trigger_error($message, E_USER_WARNING);
				
				return;
			}
			
			$line			= $lines[$phpLineNumber - 1];
			
			$numOpenTags	= preg_match_all($regexOpenTag, $line);
			$numCloseTags	= preg_match_all($regexCloseTag, $line);
			
			if($numOpenTags > $numCloseTags)
			{
				$inPhp		= true;
			}
			else if($numCloseTags > 0)
			{
				$inPhp		= false;
				$lineCounter--;	// NB: I don't understand why a close tag swallows the newline, but it does appear to
			}
			
			if(!$inPhp)
				$lineCounter++;
		}
		
		trigger_error("Failed to translate line number", E_USER_WARNING);
		trigger_error($message, E_USER_WARNING);
	}
	
	/**
	 * Loads the specified file and parses any PHP
	 * @param string $src The file you want to load
	 * @param int $options See http://php.net/manual/en/domdocument.load.php
	 * @return boolean TRUE on success, FALSE otherwise
	 */
	public function loadPHPFile($src, $options=0)
	{
		if(!file_exists($src))
			throw new \Exception("File does not exist: $src");
		
		ob_start();
		include $src;
		$html = ob_get_clean();
		
		if(empty($html))
			throw new \Exception("$src is empty");
		
		$html = DOMDocument::convertUTF8ToHTMLEntities($html);
		$suppress_warnings = !(defined('WP_DEBUG') && WP_DEBUG);
		
		// From PHP 5.4.0 onwards, loadHTML takes 2 arguments
		if(version_compare(PHP_VERSION, '5.4.0', '>='))
		{
			if($suppress_warnings)
				$result = @$this->loadHTML($html, $options);
			else
				$result = $this->loadHTML($html, $options);
		}
		else
		{
			if($suppress_warnings)
				$result = @$this->loadHTML($html);
			else
				$result = $this->loadHTML($html);
		}
		
		$this->src_file = $src;
		
		$this->onLoaded();
		
		return $result;
	}
	
	public function getDocumentElementSafe()
	{
		// Workaround for some installations of PHP missing documentElement property
		if(property_exists($this, 'documentElement'))
			return $this->documentElement;
		
		$xpath = new \DOMXPath($this);
		$result = $xpath->query('/html/body');
		$item = $result->item(0);
		
		return $item;
	}
	
	/**
	 * @internal Handles imports based on the content
	 */
	protected function handleImport($subject, $node, $forcePHP=false)
	{
		if(preg_match('/\.html(\.php)?$/i', $subject))
		{
			if(!file_exists($subject))
				throw new \Exception("File '$subject' not found in {$this->src_file} line " . $node->getLineNo());
			
			$node->import($subject, $forcePHP);
			
			return;
		}
		
		if(preg_match('/\.php$/i', $subject))
		{
			if(!file_exists($subject))
				throw new \Exception("File '$subject' not found");
			
			$before = get_declared_classes();
			require_once($subject);
			$after = get_declared_classes();
			$diff = array_diff($after, $before);
			
			$classes_in_file = array();
			
			foreach($diff as $class) {
				$reflection = new \ReflectionClass($class);
				$filename = $reflection->getFileName();
				
				if(realpath($filename) == realpath($subject))
					array_push($classes_in_file, $class);
			}
			
			if(empty($classes_in_file))
				throw new \Exception('No classes found in file');
			
			$class_name = $classes_in_file[ count($classes_in_file) - 1 ];
			$instance = new $class_name();
			
			if(!($instance instanceof DOMDocument))
				throw new \Exception('Class must be an instance of WPGMZA\\DOMDocument');
			
			$node->import($instance);
			
			return;
		}
		
		throw new \Exception("Failed to import page \"$subject\" in {$this->src_file} on line " . $node->getLineNo());
	}
	
	
	/**
	 * Runs a CSS selector on the element. This is equivilant to Javascripts querySelector
	 * @param string $query a CSS selector
	 * @return mixed The first descendant \Smart\Element that matches the selector, or NULL if there is no match
	 */
	public function querySelector($query)
	{
		if(!$this->getDocumentElementSafe())
			throw new \Exception('Document is empty');
		
		return $this->getDocumentElementSafe()->querySelector($query);
	}
	
	/**
	 * Runs a CSS selector on the element. This is equivilant to Javascripts querySelectorAll
	 * @return mixed Any descendant \Smart\Element's that match the selector, or NULL if there is no match
	 */
	public function querySelectorAll($query)
	{
		if(!$this->getDocumentElementSafe())
			throw new \Exception('Document is empty');
		
		return $this->getDocumentElementSafe()->querySelectorAll($query);
	}
	
	/**
	 * Takes an associative array or object, and populates this element with it
	 * @param mixed $src Object or associative array
	 * @return void
	 */
	public function populate($src, $formatters=null)
	{
		if(!$this->getDocumentElementSafe())
			throw new \Exception('Document is empty');
		
		return $this->getDocumentElementSafe()->populate($src, $formatters);
	}
	
	public function serializeFormData()
	{
		if(!$this->getDocumentElementSafe())
			throw new \Exception('Document is empty');
		
		return $this->getDocumentElementSafe()->serializeFormData();
	}
	
	/**
	 * Utility function to create an error element
	 * @param string $message The error message
	 * @return \Smart\Element
	 */
	public function createErrorElement($message)
	{
		$span = $this->createElement('span');
		$span->appendText($message);
		$span->addClass('notice notice-error');
		return $span;
	}
	
	/**
	 * This function saves only the inside of the <body> element of this document. This is useful when you want to import a HTML document into another, but you don't want to end up with nested <html> elements
	 * @return string The HTML string
	 */
	public function saveInnerBody()
	{
		$result = '';
		
		if(property_exists($this, 'documentElement'))
			$body = $this->querySelector('body');
		else
			$body = $this->getDocumentElementSafe();
		
		if(!$body)
			return null;
		
		for($node = $body->firstChild; $node != null; $node = $node->nextSibling)
			$result .= $this->saveHTML($node);
			
		return $result;
	}
	
	public function __get($name)
	{
		if($name == 'html')
			return $this->saveInnerBody();
		
		return null;
	}
}
<?php

namespace WPGMZA;

if(!defined('ABSPATH'))
	return;

require_once(plugin_dir_path(__FILE__) . 'class.selector-to-xpath.php');

class DOMElement extends \DOMElement
{
	protected static $xpathConverter;
	
	public function __construct()
	{
		\DOMElement::__construct();
	}
	
	public function __get($name)
	{
		switch($name)
		{
			case "html":
				
				return $this->ownerDocument->saveHTML( $this );
			
				break;
		}
		
		return \DOMElement::__get($name);
	}
	
	/**
	 * Runs a CSS selector on the element. This is equivilant to Javascripts querySelector
	 * @param string $query a CSS selector
	 * @return mixed The first descendant element that matches the selector, or NULL if there is no match
	 */
	public function querySelector($query)
	{
		$results = $this->querySelectorAll($query);		
		
		if(empty($results))
			return null;
		
		return $results[0];
	}
	
	/**
	 * Runs a CSS selector on the element. This is equivilant to Javascripts querySelectorAll
	 * @return mixed Any descendant element's that match the selector, or NULL if there is no match
	 */
	public function querySelectorAll($query, $sort=true)
	{
		$xpath 		= new \DOMXPath($this->ownerDocument);
		
		try{
			$expr 		= DOMElement::selectorToXPath($query);
		}catch(Exception $e) {
			echo "<p class='notice notice-warning'>Failed to convert CSS selector to XPath (" . $e->getMessage() . ")</p>";
		}
		
		$list	 	= $xpath->query($expr, $this);
		$results	= array();
		
		for($i = 0; $i < $list->length; $i++)
			array_push($results, $list->item($i));
		
		if($sort)
			usort($results, array('WPGMZA\DOMElement', 'sortByDOMPosition'));
		
		return new DOMQueryResults($results);
	}
	
	/** 
	 * Prepends the subject to this element.
	 * @param $subject element or array of elements
	 * @return $this element
	 */
	public function prepend($subject)
	{
		if(is_array($subject))
		{
			$originalFirst = $this->firstChild;
			
			foreach($subject as $el)
				$this->insertBefore($el, $originalFirst);
		}
		else
			$this->insertBefore($subject, $this->firstChild);
		
		return $this;
	}
	
	/**
	 * Appends the subject to this element.
	 */
	public function append($subject)
	{
		if(is_array($subject))
		{
			foreach($subject as $el)
				$this->appendChild($subject);
		}
		else
			$this->appendChild($subject);
		
		return $this;
	}
	
	/**
	 * Traverses from this node up it's ancestors and returns the first node that matches the given selector
	 * @param mixed $selector Either this node the first ancestor that matches this selector, or NULL if no match is found
	 */
	public function closest($selector)
	{
		if($this === $this->ownerDocument->getDocumentElementSafe())
			throw new \Exception('Method not valid on document element');
		
		for($el = $this; $el->parentNode != null; $el = $el->parentNode)
		{
			$m = $el->parentNode->querySelectorAll($selector);
			if(array_search($el, $m, true) !== false)
				return $el;
		}
		
		return null;
	}
	
	/**
	 * Wraps this element in the element passed in, then replaces this nodes original position
	 * @param DOMElement The element to wrap this element in
	 */
	public function wrap($wrapper)
	{
		$this->parentNode->replaceChild($wrapper, $this);
		$wrapper->appendChild($this);
	}
	
	/**
	 * Test if this element comes before the other element in the DOM tree
	 * @return boolean TRUE if this element comes before the other, FALSE if not
	 */
	public function isBefore($other)
	{
		if($this->parentNode === $other->parentNode)
			return ($this->getBreadth() < $other->getBreadth());
		
		$this_depth = $this->getDepth();
		$other_depth = $other->getDepth();
		
		if($this_depth == $other_depth)
			return $this->parentNode->isBefore($other->parentNode);
		
		if($this_depth > $other_depth)
		{
			$ancestor = $this;
			$ancestor_depth = $this_depth;
			
			while($ancestor_depth > $other_depth)
			{
				$ancestor = $ancestor->parentNode;
				$ancestor_depth--;
			}
			
			return $ancestor->isBefore($other);
		}
		
		if($this_depth < $other_depth)
		{
			$ancestor = $other;
			$ancestor_depth = $other_depth;
			
			while($ancestor_depth > $this_depth)
			{
				$ancestor = $ancestor->parentNode;
				$ancestor_depth--;
			}
			
			return $this->isBefore($ancestor);
		}
	}
	
	/**
	 * Returns the breadth (sometimes called child index) of this node in regards to it's siblings
	 * @return int The index of this node
	 */
	public function getBreadth()
	{
		$breadth = 0;
		for($node = $this->previousSibling; $node != null; $node = $node->previousSibling)
			$breadth++;
		return $breadth;
	}
	
	/**
	 * Returns the depth of this node in regards to it's ancestors
	 * @return int The depth of this node
	 */
	public function getDepth()
	{
		$depth = 0;
		for($node = $this->parentNode; $node != null; $node = $node->parentNode)
			$depth++;
		return $depth;
	}
	
	/**
	 * @internal sort function for DOM position sort
	 */
	private static function sortByDOMPosition($a, $b)
	{
		return ($a->isBefore($b) ? -1 : 1);
	}
	
	/**
	 * @internal Calls the CSS to XPath converter on the specified selector
	 * @param string $selector The CSS selector
	 * @return string The resulting XPath expression
	 */
	public static function selectorToXPath($selector)
	{
		if(!DOMElement::$xpathConverter)
			DOMElement::$xpathConverter = new Selector\XPathConverter();
		
		$xpath = DOMElement::$xpathConverter->convert($selector);
		
		return $xpath;
	}

	/**
	 * Imports the supplied subject into this node.
	 * @param mixed $subject. Either a \DOMDocument, \DOMNode, .html filename, or string containing HTML/text. The function will attempt to detect which. If you import HTML that contains a <body> element, it will only import the inner body
	 * @throws \Exception the subject was not recognised as any of the types above
	 * @return $this element
	 */
	public function import($subject, $forcePHP=false)
	{
		global $wpgmza;
		
		$node = null;
		
		if($subject instanceof \DOMDocument)
		{
			if(!$subject->getDocumentElementSafe())
				throw new \Exception('Document is empty');
			
			$node = $this->ownerDocument->importNode($subject->getDocumentElementSafe(), true);

		}
		else if($subject instanceof \DOMNode)
		{
			$node = $this->ownerDocument->importNode($subject, true);
		}
		else if(preg_match('/(\.html|\.php)$/i', $subject, $m))
		{
			// Subject is a filename
			if(!file_exists($subject))
				throw new \Exception('HTML file not found');
			
			$temp = new DOMDocument('1.0', 'UTF-8');
			if($forcePHP || preg_match('/\.php$/i', $m[1]))
				$temp->loadPHPFile($subject);
			else
				$temp->load($subject);
			
			$node = $this->ownerDocument->importNode($temp->getDocumentElementSafe(), true);
		}
		else if(is_string($subject))
		{
			if(empty($subject))
				return;
			
			if($subject != strip_tags($subject) || preg_match('/&.+;/', $subject))
			{
				// Subject is a HTML string
				$html = DOMDocument::convertUTF8ToHTMLEntities($subject);
				
				$temp = new DOMDocument('1.0', 'UTF-8');
				$str = "<div id='domdocument-import-payload___'>" . $html . "</div>";
				
				if($wpgmza->isInDeveloperMode())
					$temp->loadHTML($str);
				else
					@$temp->loadHTML($str);
				
				$body = $temp->querySelector('#domdocument-import-payload___');
				for($child = $body->firstChild; $child != null; $child = $child->nextSibling)
				{
					$node = $this->ownerDocument->importNode($child, true);
					$this->appendChild($node);
				}
			}
			else
				// Subject is a plain string
				$this->appendText($subject);
			
			return;
		}
		else if(empty($subject))
		{
			return;
		}
		else
			throw new \Exception('Don\'t know how to import "' . print_r($subject, true) . '" in ' . $this->ownerDocument->documentURI . ' on line ' . $this->getLineNo());
		
		if($body = $node->querySelector("body"))
		{
			// TODO: I don't think a query selector is necessary here. Iterating over the bodies children should be more optimal
			$results = $node->querySelectorAll("body>*");
			
			foreach($results as $child)
				$this->appendChild($child);
			
			return $results;
		}
		else
		{
			$this->appendChild($node);
			return $node;
		}
		
		return null;
	}
	
	/**
	 * Sets an inline CSS style on this element. If it's already set, the old value will be removed first
	 * @param string $name the CSS property name eg 'background-color'
	 * @param string $value the value of the property eg '#ff4400'
	 * @return $this
	 */
	public function setInlineStyle($name, $value)
	{
		$this->removeInlineStyle($name);
		$style = $this->getAttribute('style');
		$this->setAttribute('style', $style . $name . ':' . $value . ';');
		return $this;
	}
	
	/**
	 * Removes the inline CSS style specified by name
	 * @param string $name the name of the CSS property eg 'background-color'
	 * @return $this
	 */
	public function removeInlineStyle($name)
	{
		if(!$this->hasAttribute('style'))
			return;
		$style = $this->getAttribute('style');
		
		$rules = preg_split('/\s*;\s*/', $style);
		
		for($i = count($rules) - 1; $i >= 0; $i--)
		{
			$param = preg_quote($name);
			
			if(preg_match("/^$param\s*:/", trim($rules[$i])))
				unset($rules[$i]);
		}
		
		$this->setAttribute('style', implode(';', $rules));
		return $this;
	}
	
	/**
	 * Check if this element has an inline style by name
	 * @param string $name the name of the CSS property to test for
	 */
	public function hasInlineStyle($name)
	{
		if(!$this->hasAttribute('style'))
			return false;
		return preg_match("/\s*$name:.*?((;\s*)|$)/", $this->getAttribute('style'));
	}
	
	/**
	 * Gets the value of the inline style by name
	 * @param string $name the name of the CSS property you want the value for
	 * @return mixed FALSE if there is no style property or no style with that name exists, or a string containing the property value if it does
	 */
	public function getInlineStyle($name)
	{
		if(!$this->hasAttribute('style'))
			return false;
			
		$m = null;
		if(!preg_match("/\s*$name:(.*?)((;\s*)|$)/", $this->getAttribute('style')))
			return false;
			
		return $m[1];
	}
	
	/**
	 * Adds a class to this elements class attribute. It will be ignored if the class already exists
	 * @param string $name The classname
	 * @return $this
	 */
	public function addClass($name)
	{
		if($this->hasClass($name))
			return;
			
		$class = ($this->hasAttribute('class') ? $this->getAttribute('class') : '');
		$this->setAttribute('class', $class . (strlen($class) > 0 ? ' ' : '') . $name);
		
		return $this;
	}
	
	/**
	 * Removes the specified class from this nodes class attribute
	 * @param string $name The classname
	 * @return $this
	 */
	public function removeClass($name)
	{
		if(!$this->hasAttribute('class'))
			return;
			
		$class = trim(
				preg_replace('/\s{2,}/', ' ',
					preg_replace('/\\b' . $name . '\\b/', ' ', $this->getAttribute('class'))
				)
			);
			
		$this->setAttribute('class', $class);
		
		return $this;
	}
	
	/**
	 * Tests if the specified class exists on this elements class attribute
	 * @param string $name The classname
	 * @return boolean FALSE if no such class existst, TRUE if it does
	 */
	public function hasClass($name)
	{
		if(!$this->hasAttribute('class'))
			return false;
			
		return preg_match('/\\b' . $name . '\\b/', $this->getAttribute('class'));
	}
	
	/**
	 * Populates the target element. If it is a form element, the value will be set according to the elements type/nodeName. If not, the value will be imported instead.
	 * @param The target element. Usually a descendant of this element
	 * @param string $key the key of the value we're populating with, used for formatting
	 * @param string $value the value to populate with
	 * @param array $formatters an array associative of functions to format certain values with, functions should be specified by key
	 * @return void
	 */
	protected function populateElement($target, $key, $value, $formatters)
	{
		if(!($target instanceof \DOMElement))
			throw new \Exception('Argument must be a DOMElement');
		
		switch(strtolower($target->nodeName))
		{
			case 'textarea':
			case 'select':
			case 'input':
				$target->setValue($value);
				break;
				
			case 'img':
				$target->setAttribute('src', $value);
				break;
				
			default:
				if(!is_null($formatters) && isset($formatters[$key]))
					$value = $formatters[$key]($value);
					
				if($value instanceof \DateTime)
					$value = $value->format('D jS M \'y g:ia');
					
				if($key == 'price')
					$value = number_format($value, 2, '.', '');
					
				if(is_object($value))
					throw new \Exception('Expected simple type in "'.$key.'" => "'.print_r($value,true).'"');
				
				$target->import( $value );
				
				break;
		}
	}
	
	/**
	 * Takes a source object or associative array and optional array of formatting functions, and populates descendant named elements with the values from that source.
	 * @param mixed $src Associative array or object with the keys and values
	 * @param array $formatters Optional associative array of functions to format values with. The keys on this array correspond with the keys on $src
	 * @return DOMElement This element
	 */
	public function populate($src=null, $formatters=null)
	{
		$x = new \DOMXPath($this->ownerDocument);
		
		if(!$src)
			return $this;
		
		if(is_scalar($src))
		{
			$this->appendText($src);
			return $this;
		}
		
		foreach($src as $key => $value)
		{
			if(is_array($value))
			{
				$m = $x->query('descendant-or-self::*[@name="' . $key . '[]"]', $this);
				
				if($m->length > 0 && count($value) != $m->length)
				{
					if($src = $m->item(0)->closest('li,tr'))
					{
						for($i = $m->length; $i < count($value); $i++)
						{
							$item = $src->cloneNode(true);
							$src->parentNode->appendChild($item);
						}
						$m = $x->query('descendant-or-self::*[@name="' . $key . '[]"]', $this);
					}
					else
						throw new \Exception('Number of elements must match (' . count($value) . ' != ' . $m->length . ')');
				}
				
				for($i = 0; $i < $m->length; $i++)
					$this->populateElement($m->item($i), $key, $value[$i], $formatters);
			}
			else
			{
				$m = $x->query('descendant-or-self::*[@name="' . $key . '" or @data-name="' . $key . '"]', $this);
				
				for($i = 0; $i < $m->length; $i++)
					$this->populateElement($m->item($i), $key, $value, $formatters);
			}
		}
		
		return $this;
	}
	
	public function serializeFormData()
	{
		$data = array();
		
		foreach($this->querySelectorAll('input, select, textarea') as $input)
		{
			$name = $input->getAttribute('name');
			
			if(!$name)
				continue;
			
			if(preg_match('/nonce/i', $name))
				continue; // NB: Do not serialize nonce values
			
			switch($input->getAttribute('type'))
			{
				case 'checkbox':
				
					if($input->getValue())
						$data[$name] = true;
					else
						$data[$name] = false;
					
					break;
					
				case 'radio':
				
					if($input->getAttribute('checked'))
						$data[$name] = $input->getAttribute('value');
				
					break;
				
				default:
					$data[$name] = $input->getValue();
					break;
			}
		}
		
		return $data;
	}
	
	/**
	 * Gets the value of this element
	 * @return mixed A string if the element a text input, textarea or plain node, a boolean if the element is a checkbox or radio, or the value of the selected option if this element is a select
	 */
	public function getValue()
	{
		switch(strtolower($this->nodeName))
		{
			case 'input':
				$type = ($this->hasAttribute('type') ? $this->getAttribute('type') : 'text');
				switch($type)
				{
					case 'radio':
					case 'checkbox':
						return $this->hasAttribute('checked');
						break;
					
					default:
						return $this->getAttribute('value');
						break;
				}
				break;
				
			case 'select':
				$option = $this->querySelector('option[selected]');
				if(!$option)
					return null;
				
				if($option->hasAttribute('value'))
					return $option->getAttribute('value');
				
			default:
				return $this->nodeValue;
				break;
		}
	}
	 
	/**
	 * Sets the value of this element. Intended for form elements only. If this element is a textarea, it will be appended as plain text. If this element is a select, it will attempt to select the option with the specified value. If the input is a radio or checkbox, it will set it accordingly. Otherwise, the value will be put into the value attribute
	 * @throws \Exception If this element is a select, SMART_STRICT_MODE is declared and no option with that value exists
	 * @throws \Exception If you call this method on a non-form element
	 * @return This element
	 */
	public function setValue($value)
	{
		switch(strtolower($this->nodeName))
		{
			case 'textarea':
				$this->clear();
				$this->appendText( $value );
				break;
			
			case 'select':
				$deselect = $this->querySelectorAll('option[selected]');
				foreach($deselect as $d)
					$d->removeAttribute('selected');
				
				if($value === null)
					return $this;
				
				$option = $this->querySelector('option[value="' . $value . '"]');
				
				if(!$option)
					trigger_error('Option with value "' . $value . '" not found in "' . ($this->getAttribute('name')) . '"', E_USER_WARNING);
				else
					$option->setAttribute('selected', 'selected');
				
				break;
				
			case 'input':
				if(!$this->hasAttribute('type') || $this->getAttribute('type') == 'text')
				{
					if(is_string($value))
						$this->setAttribute('value', $value);
				}
				else switch(strtolower($this->getAttribute('type')))
				{
					case 'radio':
						if($this->hasAttribute('value') && $this->getAttribute('value') == $value)
							$this->setAttribute('checked', 'checked');
						else
							$this->removeAttribute('checked');
						break;
						
					case 'checkbox':
						if(!empty($value) && $value != false)
							$this->setAttribute('checked', 'checked');
						else
							$this->removeAttribute('checked');
						break;
						
					default:
						$this->setAttribute('value', $value);
						break;
				}
				break;
				
			default:
				throw new \Exception('Not yet implemented');
				
				$this->nodeValue = $value;
				break;
		}
		
		return $this;
	}
	
	/**
	 * Appends the specified text to this element, shorthand utility function
	 * @return \Smart\Element This element 
	 */
	public function appendText($text)
	{
		$this->appendChild( $this->ownerDocument->createTextNode( $text ) );
		return $this;
	}

	/**
	 * Utility function to append the specified element after one of this elements children. Will append at the end if after is null
	 * @param \Smart\Element the element to insert
	 * @param \Smart\Element one of this elements children, or null
	 * *@return \Smart\Element this element
	 */
	public function insertAfter($elem, $after=null)
	{
		if($after->parentNode && $after->parentNode !== $this)
			throw new \Exception('Hierarchy error');
		
		if($after->nextSibling)
			$this->insertBefore($elem, $after->nextSibling);
		else
			$this->appendChild($elem);
		
		return $this;
	}
	
	/**
	 * Clears this element, completely removing all it's contents
	 * @return \Smart\Element This element
	 */
	public function clear()
	{
		while($this->childNodes->length)
			$this->removeChild($this->firstChild);
		return $this;
	}
	 
	/**
	 * Removes this element from it's parent
	 * @return \Smart\Element This element
	 */
	public function remove()
	{
		if($this->parentNode)
			$this->parentNode->removeChild($this);
		return $this;
	}
}

<?php

namespace WPGMZA;

class DOMQueryResults implements \ArrayAccess, \Countable, \Iterator
{
	private $index = 0;
	private $container;
	
	public function __construct(array $arr = null)
	{
		if(!empty($arr))
			$this->container = $arr;
		else
			$this->container = array();
	}
	
	public function __call($name, $arguments)
	{
		foreach($this->container as $element)
		{
			if(!method_exists($element, $name))
				throw new \Exception("No such method '$name' on " . get_class($element));
			
			call_user_func_array(
				array($element, $name),
				$arguments
			);
		}
		
		return $this;
	}
	
	public function offsetExists($offset)
	{
		return isset($this->container[$offset]);
	}
	
	public function offsetGet($offset)
	{
		return isset($this->container[$offset]) ? $this->container[$offset] : null;
	}
	
	public function offsetSet($offset, $value)
	{
		if(!($value instanceof DOMElement))
			throw new \Exception("Only DOMElement is permitted in query results");
		
		if(is_null($offset))
			$this->container[] = $value;
		else
			$this->container[$offset] = $value;
	}
	
	public function offsetUnset($offset)
	{
		unset($this->container[$offset]);
	}
	
	public function count()
	{
		return count($this->container);
	}
	
	public function current()
	{
		return $this->container[$this->index];
	}
	
	public function next()
	{
		$this->index++;
	}
	
	public function key()
	{
		return $this->index;
	}
	
	public function valid()
	{
		return isset($this->container[$this->key()]);
	}
	
	public function rewind()
	{
		$this->index = 0;
	}
	
	public function reverse()
	{
		$this->container = array_reverse($this->container);
		$this->rewind();
	}
}
<?php
/**
 * This file contains several classes used by DOMDocument to parse CSS selectors and return their XPath equivalents.
 * These modules are mostly for internal use, however are documented here for convenience.
 */

namespace WPGMZA\Selector;

if(!defined('ABSPATH'))
	return;

/**
 * Useful when debugging CSS selector to XPath query conversion
 * @param string $str The string to output
 */
function trace($str)
{
	echo $str . "\r\n";
}

/**
 * An exception thrown when parsing a CSS selector fails (ie failed to interpret the selector, before conversion starts)
 */
class ParseException extends \Exception
{
	/**
	 * @var The CSS selector that caused this exception
	 */
	public $css;
	
	/**
	 * Constructor.
	 * @param string $message The error message
	 * @param int $code Unused, the error code
	 * @param \Exception $previous The previous exception, used for exception chaining
	 */
	public function __construct($message, $code = 0, \Exception $previous = null) {
        \Exception::__construct($message, $code, $previous);
    }
}

/**
 * An exception thrown when conversion from a CSS selector to an XPath query failed (ie the selector was successfully parsed, but conversion to it's XPath equivalent failed).
 */
class ConvertException extends \Exception
{
	/**
	 * Constructor.
	 * @param string $message The error message
	 * @param int $code Unused, the error code
	 * @param \Exception $previous The previous exception, used for exception chaining
	 */
	public function __construct($message, $code = 0, Exception $previous = null) {
        \Exception::__construct($message, $code, $previous);
    }
}

/**
 * This class represents a CSS selector token
 * @method __toString The "friendly" type of this token, tabs and then the raw string.
 */
class Token
{
	const OPEN_BRACKET							= '[';
	const CLOSE_BRACKET							= ']';
	const OPEN_PARENTHES						= '(';
	const CLOSE_PARENTHES						= ')';
	const CLASS_SHORTHAND						= '.';
	const ID_SHORTHAND							= '#';
	const SINGLE_QUOTE							= "'";
	const ANY_ELEMENT							= '*';
	const DOUBLE_QUOTE							= '"';
	const PSEUDO								= ':';
	const UNION_OPERATOR						= ',';
	const DESCENDANT_COMBINATOR					= ' ';
	const CHILD_COMBINATOR						= '>';
	const ADJACENT_SIBLING_COMBINATOR			= '+';
	const GENERAL_SIBLING_COMBINATOR			= '~';
	const ATTRIBUTE_EQUAL_TO					= '=';
	const ATTRIBUTE_WHITESPACE_LIST_CONTAINS	= '~=';
	const ATTRIBUTE_BEGINS_WITH					= '^=';
	const ATTRIBUTE_ENDS_WITH					= '$=';
	const ATTRIBUTE_CONTAINS_SUBSTRING			= '*=';
	const ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH		= '|=';
	
	const IDENTIFIER							= -1;
	const STRING								= -2;
	const EXPRESSION							= -3;
	
	public $type = null;
	public $string;
	
	/**
	 * Constructor.
	 * @param string $type Any of the constants defined in this class.
	 * @param string $string Either a string matching the values of the constants defined in this class, or where $type is IDENTIFIER, STRING or EXPRESSION, the string representing that element of the selector
	 */
	public function __construct($type, $string)
	{
		if(empty($string) && $string !== '0' || $string === '')
			throw new \Exception('Token string cannot be empty');
		
		$this->type = $type;
		$this->string = $string;
		
		//trace("Created token '$string' with type " . $this->getFriendlyType() . "\r\n");
	}
	
	/**
	 * Retuns true if this token is a CSS combinator (eg > + ~)
	 * @return bool
	 */
	public function isCombinator()
	{
		switch($this->type)
		{
			case Token::DESCENDANT_COMBINATOR:
			case Token::CHILD_COMBINATOR:
			case Token::ADJACENT_SIBLING_COMBINATOR:
			case Token::GENERAL_SIBLING_COMBINATOR:
				return true;
		}
		return false;
	}
	
	/**
	 * Returns true if this token is a CSS attribute operator (eg ^= *= |=)
	 * @return bool
	 */
	public function isAttributeOperator()
	{
		switch($this->type)
		{
			case Token::ATTRIBUTE_EQUAL_TO:
			case Token::ATTRIBUTE_WHITESPACE_LIST_CONTAINS:
			case Token::ATTRIBUTE_BEGINS_WITH:
			case Token::ATTRIBUTE_ENDS_WITH:
			case Token::ATTRIBUTE_CONTAINS_SUBSTRING:
			case Token::ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH:
				return true;
				break;
		}
		return false;
	}
	
	/**
	 * Returns the "friendly" type name, eg "IDENTIFIER" for -1, "DOUBLE_QUOTE" for ".
	 * @return string The constant name based on type value.
	 */
	public function getFriendlyType()
	{
		$ref = new \ReflectionClass(__CLASS__);
		$constants = $ref->getConstants();
		
		foreach($constants as $type => $string)
		{
			if($string == $this->type)
			{
				return $type;
			}
		}
			
		return "NULL";
	}
	
	public function __toString()
	{
		$friendly = $this->getFriendlyType();
		$spaces = 36 - strlen($friendly);
		return $friendly . str_repeat(" ", $spaces) . $this->string;
	}
}

/**
 * This class provides stream functions to navigate an array of tokens
 */
class TokenStream
{
	protected $tokens;
	protected $cursor;
	
	/**
	 * Constructor
	 * @param Token[] An array of tokens
	 */
	public function __construct($arr)
	{
		$this->tokens = $arr;
		$this->cursor = 0;
	}
	
	/**
	 * Peeks at the next token in the stream, without advancing the cursor
	 * @param string|null $expectedType The token type to expect.
	 * @param int $calledByRead Used for internal debug logging.
	 * @throws ParseException If $expectedType is non-null, and the peeked token is null or not the expected type.
	 * @return Token|null The peeked token, or null at the end of the stream.
	 */
	public function peek($expectedType=null, $calledByRead=0)
	{
		//$backtrace = debug_backtrace();
		$token = (isset($this->tokens[$this->cursor]) ? $this->tokens[$this->cursor] : null);
		
		if($expectedType !== null)
		{
			if($token == null)
				throw new ParseException('Unexpected end');
			if($token->type != $expectedType)
				throw new ParseException('Unexpected ' . $token->getFriendlyType() . ' "' . $token->string . '", expecting "' . $expectedType . '"');
		}
		
		$action = ($calledByRead ? 'Read' : 'Peeked at');
		//trace($backtrace[1+$calledByRead]["class"] . '::' . $backtrace[1+$calledByRead]["function"] . "() [" . $backtrace[0+$calledByRead]["line"] . "]\t:- $action token $token");
		return $token;
	}
	
	/**
	 * Reads the next token in the stream. This performs the same actions as peek, but will advance the cursor before returning the token. The cursor may not advance past the token count.
	 * @param string|null $expectedType The token type to expect.
	 * @throws ParseException If $expectedType is non-null, and the peeked token is null or not the expected type.
	 * @return Token|null The peeked token, or null at the end of the stream.
	 */
	public function read($expectedType=null)
	{
		$token = $this->peek($expectedType, 1);
		
		if(++$this->cursor >= count($this->tokens))
			$this->cursor = count($this->tokens);
		
		return $token;
	}
	
	/**
	 * Returns true if the cursor has reached the end of the token stream
	 * @return bool
	 */
	public function eof()
	{
		return ($this->cursor >= count($this->tokens));
	}
}

/**
 * This class is used to convert CSS strings into an array of CSS tokens
 */
class Tokenizer
{
	protected $tokens;
	protected $prevToken;
	
	/**
	 * Pushes a new token to the token array
	 * @param string $type The token type, @see Token
	 * @param string $char The character(s) to initialize the new token with
	 */
	protected function pushToken($type, $char)
	{
		return array_push($this->tokens, new Token($type, $char));
	}
	
	/**
	 * Either pushes the specified character to the current token if the current token is a string, or initializes and pushes a new token to the token array.
	 * @param string $char The character(s) to push
	 */
	protected function pushCharToString($char)
	{
		//trace("Pushing '$char'\r\n");
		
		if(($curr = $this->getCurrToken()) && $curr->type == Token::STRING)
			$curr->string .= $char;
		else
			$this->pushToken(Token::STRING, $char);
	}

	/**
	 * Either pushes the specified character to the current token if the current token is an identifier, or initializes and pushes a new token to the token array.
	 * @param string $char The character(s) to push
	 */
	protected function pushCharToIdentifier($char)
	{
		//trace("Pushing '$char'\r\n");
		
		if(($curr = $this->getCurrToken()) && $curr->type == Token::IDENTIFIER)
			$curr->string .= $char;
		else
			$this->pushToken(Token::IDENTIFIER, $char);
	}
	
	/**
	 * Either pushes the specified character to the current token if the current token is an expression, or initializes and pushes a new token to the token array.
	 * @param string $char The character(s) to push
	 */
	protected function pushCharToExpression($char)
	{
		//trace("Pushing '$char'\r\n");
		
		if(($curr = $this->getCurrToken()) && $curr->type == Token::EXPRESSION)
			$curr->string .= $char;
		else
			$this->pushToken(Token::EXPRESSION, $char);
	}
	
	/**
	 * Pops a token from the end of the token array
	 * @return Token the popped token
	 */
	protected function popToken()
	{
		$result = array_pop($this->tokens);
		//trace("Popped token " . $result . "\r\n");
		return $result;
	}
	
	/**
	 * Gets the current token (the last token in the token array)
	 * @return Token|null The current token, or null if no tokens are in the array
	 */
	protected function getCurrToken()
	{
		if(empty($this->tokens))
			return null;
		return $this->tokens[ count($this->tokens) - 1 ];
	}
	
	/**
	 * Attempts to tokenize the specified string
	 * @param string $str The input string
	 * @throws ParseException When parsing the string fails due to invalid CSS
	 * @return Token[] An array of tokens parsed from the string
	 */
	public function tokenize($str)
	{
		// Tokenize
		$str = preg_replace('/\s+/', ' ', trim($str));
		$length = strlen($str);
		
		$cursor = 0;
		$flags = (object)array(
			'brackets'	 		=> 0,
			'parentheses'		=> array(),
			'string'			=> false,
			'escaped'			=> false
		);

		$this->tokens = array();
		
		for($cursor = 0; $cursor < $length; $cursor++)
		{
			$curr = $this->getCurrToken();
			$char = substr($str, $cursor, 1);
			$next_two_chars = substr($str, $cursor, 2);
			
			//trace(preg_replace('/\r?\n/', '  ', $str) . "\r\n" . str_repeat(' ', $cursor) . "^ $cursor");
			//trace("Current token: " . $curr);
			
			if(!$flags->escaped)
			{
				switch($next_two_chars)
				{
					case Token::ATTRIBUTE_WHITESPACE_LIST_CONTAINS:
					case Token::ATTRIBUTE_BEGINS_WITH:
					case Token::ATTRIBUTE_ENDS_WITH:
					case Token::ATTRIBUTE_CONTAINS_SUBSTRING:
					case Token::ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH:
						$this->pushToken($next_two_chars, $next_two_chars);
						$cursor++;
						continue 2;
				}
			}
			
			if($char == "\\")
			{
				if($flags->escaped)
					$this->pushCharToString($char);
			
				$flags->escaped = !$flags->escaped;
				//trace($flags->escaped ? "Escaped" : "Unescaped");
			}
			else if($flags->string)
			{
				//trace("Reading {$flags->string} quoted string");
				switch($char)
				{
					case Token::SINGLE_QUOTE:
					case Token::DOUBLE_QUOTE:
						if($flags->escaped)
						{
							$this->pushCharToString($char);
							break;
						}
					
						$double = ($char == '"');
						
						if(($double && $flags->string == 'double') || (!$double && $flags->string == 'single'))
						{
							$flags->string = false;
							$this->pushToken(
								$double ? Token::DOUBLE_QUOTE : Token::SINGLE_QUOTE,
								$char
							);
							//trace("Exited {$flags->string} quoted string");
						}
						else
							$this->pushCharToString($char);
						break;
						
					default:
						$this->pushCharToString($char);
						break;
				}
				
				if($flags->escaped)
				{
					$flags->escaped = false;
					//trace("Unescaped at end of reading string");
				}
			}
			else if($flags->escaped)
			{
				$this->pushCharToIdentifier($char);
				$flags->escaped = false;
				//trace("Unescaped in else-if clause");
			}
			else
			{
				switch($char)
				{
					case Token::SINGLE_QUOTE:
					case Token::DOUBLE_QUOTE:
						if($flags->escaped)
						{
							$this->pushCharToIdentifier($char);
							break;
						}
					
						$double = ($char == '"');
					
						$flags->string = ($double ? 'double' : 'single');
						$this->pushToken(
							$double ? Token::DOUBLE_QUOTE : Token::SINGLE_QUOTE,
							$char
						);
						
						//trace("Entered {$flags->string} quoted string");
						break;
					
					case Token::OPEN_BRACKET:
						$var = ($char == Token::OPEN_BRACKET ? 'brackets' : 'parentheses');
					
						$flags->brackets++;
					
						if($flags->brackets > 1)
							throw new ParseException('Unexpected ' . $char);
						
						$this->pushToken($char, $char);
						//trace("Entered brackets");
						
						break;
					
					case Token::CLOSE_BRACKET:
						$flags->brackets--;
					
						if($flags->brackets < 0)
							throw new ParseException('Unexpected ' . $char);
						
						$this->pushToken($char, $char);
						//trace("Exited brackets");
					
						break;
					
					case Token::OPEN_PARENTHES:
						array_push($flags->parentheses, $curr->string);
						$this->pushToken($char, $char);
						//trace("Entered brackets");
						break;

					case Token::CLOSE_PARENTHES:
						array_pop($flags->parentheses);
						$this->pushToken($char, $char);
						//trace("Exited brackets");
						break;
						
					case Token::UNION_OPERATOR:						
					case Token::CLASS_SHORTHAND:
					case Token::ID_SHORTHAND:
					case Token::ANY_ELEMENT:
					case Token::PSEUDO:
					case Token::UNION_OPERATOR:
					case Token::ATTRIBUTE_EQUAL_TO:
						if($flags->escaped)
							break;
						
						$this->pushToken($char, $char);
						
						break;
						
					case Token::ADJACENT_SIBLING_COMBINATOR:
						if(count($flags->parentheses) > 0)
						{
							$this->pushCharToExpression($char);
							break;
						}
					case Token::CHILD_COMBINATOR:
					case Token::GENERAL_SIBLING_COMBINATOR:
						$curr = $this->getCurrToken();
						if($curr && $curr->type == Token::DESCENDANT_COMBINATOR)
							$this->popToken();
						
						$this->pushToken($char, $char);
						
						break;
						
					case " ":
					case "\r":
					case "\n":
					case "\t":
						$curr = $this->getCurrToken();
						
						if($flags->brackets > 0 || count($flags->parentheses) > 0)
						{
							break;
						}
						if($curr)
						{
							if($curr->type == Token::UNION_OPERATOR)
								break;
							if($curr->isCombinator())
								break;
						}
						else
							break;
						
						$this->pushToken(Token::DESCENDANT_COMBINATOR, $char);
						
						break;
						
					default:
						if(count($flags->parentheses) > 0 && !preg_match('/not/i', $flags->parentheses[count($flags->parentheses) - 1]))
							$this->pushCharToExpression($char);
						else
							$this->pushCharToIdentifier($char);
						break;
				}
			}
			
			//trace("");
		}
		
		return $this->tokens;
	}
}

/**
 * This class represents a CSS pseudo selector, for example, :nth-child, :empty, :not
 */
class PseudoSelector
{
	public $name;
	public $expression;
	public $selector;
	
	/**
	 * Parses this selector from the given stream, on the supplied selector
	 * @param TokenStream $stream The token stream to read from
	 * @param Selector $selector The CSS selector this pseudo-selector is part of
	 * @throws ParseException Pseudo selector not supported server side
	 * @throws ParseException Pseudo selector not yet implemented
	 * @throws ParseException Unknown pseudo selector
	 * @throws ParseException :not pseudo selector cannot be nested (as per the CSS specs)
	 * @throws ParseException Invalid CSS in the selector
	 * @return void
	 */
	public function parse($stream, $selector)
	{
		$first = $stream->read(Token::PSEUDO);
		$token = $stream->read(Token::IDENTIFIER);
		
		$this->name = strtolower($token->string);
		switch($this->name)
		{
			case 'nth-child':
			case 'nth-last-child':
			case 'nth-of-type':
			case 'nth-last-of-type':
			case 'not':
				break;
			
			case 'first-child':
			case 'last-child':
			case 'first-of-type':
			case 'last-of-type':
			case 'only-child':
			case 'only-of-type':
			case 'empty':
			case 'enabled':
			case 'disabled':
			case 'checked':
				return;
				break;
			
			case 'link':
			case 'visited':
			case 'active':
			case 'hover':
			case 'focus':
			case 'target':
				throw new ParseException('Pseudo selector not supported server side');
				break;
			
			case 'root':
				// See https://en.wikibooks.org/wiki/XPath/CSS_Equivalents for root. Will need to pop descendant:: off stack
			
			case 'lang':
				throw new ParseException('Pseudo selector not yet implemented');
				break;
				
			default:
				throw new ParseException('Unknown pseudo selector');
				break;
		}
		
		$stream->read(Token::OPEN_PARENTHES);
		if($this->name == 'not')
		{
			if($selector->parent)
			{
				foreach($selector->parent->selector->pseudos as $parent_pseudo)
				{
					if($parent_pseudo->name == 'not')
						throw new ParseException(':not pseudo selector cannot be nested');
				}
			}
			
			$this->selector = new Selector($stream);
			$this->selector->parent = $this;
			$this->selector->parse($stream, $this);
		}
		else
			$this->expression = $stream->read(Token::EXPRESSION);
		$stream->read(Token::CLOSE_PARENTHES);
	}
}

/**
 * A CSS attribute selector, such as [data-example], [data-example="value"] or [data-example$="ends-with"]
 */
class AttributeSelector
{
	public $name;
	public $operator;
	public $value;
	
	/**
	 * Parses the attribute selector from the supplied stream. Please note these classes expect attribute selectors to be enclosed in quotes.
	 * @param TokenStream $stream The token stream to read from
	 * @throws ParseException Unexpected end in attribute
	 * @throws ParseException Expected either close bracket or attribute operator
	 * @throws ParseException Unexpected end in attribute
	 * @throws ParseException Expected quote to open string after attribute operator
	 * @throws ParseException Unexpected end in attribute
	 * @throws ParseException Expected quote to terminate string after attribute operator
	 * @throws ParseException Invalid CSS eg unexpected tokens
	 */
	public function parse($stream)
	{
		// Expect [ first
		$stream->read(Token::OPEN_BRACKET);
		
		// Read name
		$token = $stream->read(Token::IDENTIFIER);
		$this->name = $token->string;
		
		// Read operator
		$token = $stream->read();
		if(!$token)
			throw new ParseException('Unexpected end in attribute');
		if($token->type == Token::CLOSE_BRACKET)
			return;	// has attribute
		if(!$token->isAttributeOperator())
			throw new ParseException('Expected either close bracket or attribute operator');
		$this->operator = $token->string;
		
		// Read string value
		$token = $stream->read();
		if(!$token)
			throw new ParseException('Unexpected end in attribute');
		if($token->type != Token::SINGLE_QUOTE && $token->type != Token::DOUBLE_QUOTE)
			throw new ParseException('Expected quote to open string after attribute operator');
		$openQuoteType = $token->type;

		$after = $stream->peek();
		if($after->type == $openQuoteType)
		{
			// Empty string
			$this->value = '';
		}
		else
		{
			// Read value
			$token = $stream->read(Token::STRING);
			$this->value = $token->string;
		}
		
		$token = $stream->read();
		if(!$token)
			throw new ParseException('Unexpected end in attribute');
		if($token->type != Token::SINGLE_QUOTE && $token->type != Token::DOUBLE_QUOTE)
			throw new ParseException('Expected quote to terminate string after attribute operator');
		
		// Expect ]
		$stream->read(Token::CLOSE_BRACKET);
	}
}

/**
 * Represents a single selector, either standalone or as part of a compound selector
 */
class Selector
{
	public $element = '*';
	public $id;
	public $classes;
	public $attributes;
	public $pseudos;
	
	public $parent;
	
	/**
	 * Parses this selector from the given stream.
	 * @param TokenStream $stream The token stream to read from.
	 * @param PseudoSelector $not The :not pseudo selector that contains this selector.
	 * @throws ParseException Unexpected end in attribute
	 * @throws ParseException Expected either close bracket or attribute operator
	 * @throws ParseException Unexpected end in attribute
	 * @throws ParseException Expected quote to open string after attribute operator
	 * @throws ParseException Unexpected end in attribute
	 * @throws ParseException Expected quote to terminate string after attribute operator
	 * @throws ParseException Invalid CSS eg unexpected tokens
	 */
	public function parse($stream, $not=null)
	{
		$first = $stream->peek();
		
		switch($first->type)
		{
			case Token::ANY_ELEMENT:
			case Token::IDENTIFIER:
				$this->element = $stream->read()->string;
				break;
				
			case Token::OPEN_BRACKET:
			case Token::PSEUDO:
			case Token::ID_SHORTHAND:
			case Token::CLASS_SHORTHAND:
				break;
				
			default:
				throw new ParseException("Unexpected token '{$token->string}'");
				break;
		}
		
		while($token = $stream->peek())
		{
			if($token->isCombinator() || $token->type == Token::UNION_OPERATOR)
				return;
			
			switch($token->type)
			{
				case Token::ID_SHORTHAND:
					if($this->id != null)
						throw new ParseExcepton('Selector can only have one ID');
					$stream->read(Token::ID_SHORTHAND);
					$this->id = $stream->read(Token::IDENTIFIER)->string;
					//trace("Read ID as {$this->id}");
					continue 2;
					break;
					
				case Token::CLASS_SHORTHAND:
					if(!$this->classes)
						$this->classes = array();
					$stream->read(Token::CLASS_SHORTHAND);
					array_push($this->classes, $stream->read(Token::IDENTIFIER)->string);
					continue 2;
					break;
				
				case Token::OPEN_BRACKET:
					if(!$this->attributes)
						$this->attributes = array();
				
					$attributeSelector = new AttributeSelector();
					array_push($this->attributes, $attributeSelector);
					$attributeSelector->parse($stream);
					continue 2;
					break;
				
				case Token::PSEUDO:
					if(!$this->pseudos)
						$this->pseudos = array();
					
					$pseudoSelector = new PseudoSelector();
					array_push($this->pseudos, $pseudoSelector);
					$pseudoSelector->parse($stream, $this);
					continue 2;
					break;
					
				case Token::CLOSE_PARENTHES:
					if($not != null)
						return;
					
				default:
					throw new ParseException("Unexpected token '{$token->string}'");
					break;
			}
			
			$stream->read();
		}
		
		
	}
}

/**
 * Used to parse a selector or compound selectors
 */
class Parser
{
	protected $tokenizer;
	protected $elements;
	
	/**
	 * Constructor.
	 */
	public function __construct()
	{
		$this->tokenizer = new Tokenizer();
	}
	
	/**
	 * Parses the selector(s) supplied
	 * @param string $selector The string of selector(s) to parse
	 * @return Selector[] An array of selectors parsed from the string
	 */
	public function parse($selector)
	{
		$tokens = $this->tokenizer->tokenize($selector);
		$stream = new TokenStream($tokens);
		$this->elements = array();
		
		while(!$stream->eof())
		{
			$token = $stream->peek();
			if($token->isCombinator() || $token->type == Token::UNION_OPERATOR)
			{
				if(empty($this->elements))
					throw new ParseException('Expected selector before combinator or union operator');
				array_push($this->elements, $stream->read());
			}
	
			$selector = new Selector();
			$selector->parse($stream);
			array_push($this->elements, $selector);
			
			//trace(print_r($selector, true));
		}
		
		return $this->elements;
	}

	
}

/**
 * Used to convert CSS selectors to XPath queries
 */
class XPathConverter
{
	protected $parser;
	protected $xpath;
	
	/**
	 * Constructor.
	 */
	public function __construct()
	{
		$this->parser = new Parser();
	}
	
	/**
	 * Converts a CSS attribute selector to its XPath equivalent and pushes the XPath query string to memory
	 * @param AttributeSelector $attr The CSS attribute selector
	 * @throws ConvertException Unrecognised attribute operator
	 * @return void
	 */
	protected function convertAttribute($attr)
	{
		$name = $attr->name;
		$value = addslashes($attr->value);
		
		switch($attr->operator)
		{
			case null:
				$inner = "@{$name}";
				break;
				
			case Token::ATTRIBUTE_EQUAL_TO:
				$inner = "@$name=\"$value\"";
				break;
			
			case Token::ATTRIBUTE_WHITESPACE_LIST_CONTAINS:
				$inner = "conatins(concat(\" \", normalize-space(@$name), \" \"), \" $value \")";
				break;
			
			case Token::ATTRIBUTE_BEGINS_WITH:
				$inner = "starts-with(@$name, \"$value\")";
				break;
			
			case Token::ATTRIBUTE_ENDS_WITH:
				$inner = "substring(@$name, string-length(@$name) - string-length(\"$value\") + 1) = \"$value\"";
				break;
			
			case Token::ATTRIBUTE_CONTAINS_SUBSTRING:
				$inner = "contains(@$name, \"$value\")";
				break;
			
			case Token::ATTRIBUTE_HYPHEN_LIST_BEGINS_WITH:
				$inner = "@$name=\"@$value\" | starts-with(@$name, \"@$value-\")";
				break;
				
			default:
				throw new ConvertException("Don't know how to convert operator {$attr->operator}");
				break;
		}
		array_push($this->xpath, "[$inner]");
	}
	
	/**
	 * Converts a CSS pseudo selector to its XPath equivalent and pushes the XPath query string to memory
	 * @param AttributeSelector $pseudo The CSS pseudo selector
	 * @throws ConvertException Don't know how to convert selector (may not be implemented)
	 * @return void
	 */
	protected function convertPseudo($pseudo)
	{
		$name = $pseudo->name;
		
		// TODO: The "of type" selectors should change the parent selector element to *, and should use that type in their xpath
		// TODO: Test with a live domdocument in here (or smartdocument even)
		
		switch($name)
		{
			case 'nth-child':
			case 'nth-last-child':
			case 'nth-of-type':
			case 'nth-last-of-type':
				// TODO: Support formulas / expressions
				throw new ConvertException('Not yet implemented');
				break;
			
			case 'first-child':
			case 'first-of-type':
				$inner = '1';
				break;
			
			case 'last-child':
			case 'last-of-type':
				$inner = 'last()';
				break;
			
			case 'only-child':
			case 'only-of-type':
				$inner = 'count(*)=1';	// TODO: might need to swap * with node name
				break;
			
			case 'empty':
				$inner = 'count(./*)=0 and string-length(text())=0';
				break;
				
			case 'enabled':
				$inner = "not(@disabled)";
				break;
			
			case 'disabled':
			case 'checked':
				$inner = "@$name";
				break;
				
			case 'not':
				throw new ConvertException('Not yet implemented');
				break;
			
			default:
				throw new ConvertException("Don't know how to convert pseudo selector {$pseudo->name}");
				break;
		}
		
		array_push($this->xpath, "[$inner]");
	}
	
	/**
	 * Converts a CSS selector to its XPath equivalent and pushes the XPath query string to memory
	 * @param Selector $selector The CSS selector to convert.
	 * @return void
	 */
	protected function convertSelector($selector)
	{
		//trace("Converting selector " . print_r($selector, true));
		
		$prev = null;
		if(!empty($this->xpath))
			$prev = &$this->xpath[ count($this->xpath) - 1 ];
		
		if($prev == 'following-sibling::*[1]')
			$prev = preg_replace('/\*/', $selector->element, $prev);
		else
			array_push($this->xpath, $selector->element);
		
		if($selector->id != null)
			array_push($this->xpath, '[@id="' . $selector->id . '"]');
		
		if($selector->classes)
			foreach($selector->classes as $class)
			{
				array_push($this->xpath,
					'[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]'
				);
			}
			
		if($selector->attributes)
			foreach($selector->attributes as $attr)
				$this->convertAttribute($attr);
				
		if($selector->pseudos)
			foreach($selector->pseudos as $pseudo)
				$this->convertPseudo($pseudo);
	}
	
	/**
	 * Converts an element (eg a single, non-compound CSS selector) to it's XPath equivalent
	 * @param Selector $element The selector to convert
	 * @throws ConvertException Unexpected element
	 */
	protected function convertElement($element)
	{
		//trace("Converting element " . print_r($element, true));
		
		$prev = null;
		if(!empty($this->xpath))
			$prev = $this->xpath[ count($this->xpath) - 1 ];
		
		if($element instanceof Token)
		{
			if($prev != "\r\n|\r\n" && $element->type != Token::UNION_OPERATOR)
				array_push($this->xpath, '/');
			
			switch($element->type)
			{
				case Token::UNION_OPERATOR:
					array_push($this->xpath, "\r\n|\r\n");
					
				case Token::DESCENDANT_COMBINATOR:
					array_push($this->xpath, 'descendant::');
					return;
					
				case Token::CHILD_COMBINATOR:
					array_push($this->xpath, 'child::');
					return;
					
				case Token::GENERAL_SIBLING_COMBINATOR:
					array_push($this->xpath, 'following-sibling::');
					return;
					
				case Token::ADJACENT_SIBLING_COMBINATOR:
					array_push($this->xpath, 'following-sibling::*[1]');
					return;
					
				default:
					throw new ConvertException('Unexpected token');
					break;
			}
		}
		else if($element instanceof Selector)
			$this->convertSelector($element);
		else
			throw new ConvertException('Unexpected element');
	}
	
	/**
	 * Converts the given CSS selector string into it's XPath equivalent
	 * @param string $str The input CSS selector to convert
	 * @return string The XPath equivalent of the supplied selector
	 */
	public function convert($str)
	{
		//trace("=== Parsing $str ===\r\n");
		
		$this->xpath = array('descendant::');
		$elements = $this->parser->parse($str);
		
		//trace("=== Converting $str ===\r\n");
		foreach($elements as $el)
			$this->convertElement($el);
		
		return implode('', $this->xpath);
	}
}

?>

This allows the server to have some contextual awareness of a HTML form when processing POST data – the server can work with the client to use attributes like pattern and required. It can also standardise the way form input is processed, for example, checkboxes can be treated as true or false rather than set or not set. The extension can dynamically populate and serialize forms.

The extension provides a CSS selector parser and implementation of querySelector (and querySelectorAll) which can be used to quickly manipulate the DOM tree, especially for developers who don’t want to be concerned with, or who aren’t familiar with XPath.

A DOMQueryResults class is provided which facilitates calling available DOM functions (such as setAttribute or remove) on sets of matched elements, very similar to the way jQuery functions work.

As well as all that, the extension provides many convenience functions such as prepend, closest, wrap, addClass, removeClass, insertAfter, clear, remove and more.

See https://github.com/CodeCabin/wp-google-maps/blob/master/includes/class.dom-document.php for more information.

Leave a Reply

Your email address will not be published. Required fields are marked *