diff --git a/lib/Collection.php b/lib/Collection.php new file mode 100644 index 0000000..78d7982 --- /dev/null +++ b/lib/Collection.php @@ -0,0 +1,109 @@ +count(); + } + + + protected function __construct(InnerDocument $innerDocument, \DOMNodeList $nodeList) { + $this->innerDocument = $innerDocument; + $this->innerCollection = $nodeList; + } + + + public function count(): int { + return $this->innerCollection->length; + } + + public function current(): ?Node { + return $this->item($this->position); + } + + public function item(int $index): ?Node { + # The item(index) method must return the indexth node in the collection. If + # there is no indexth node in the collection, then the method must return null. + // PHP's DOM does this okay already + $node = $this->innerCollection->item($index); + if ($node === null) { + return null; + } + + return $this->innerDocument->getWrapperNode($node); + } + + public function key(): int { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function rewind(): void { + $this->position = 0; + } + + public function offsetExists($offset): bool { + return ($this->innerCollection->item($offset) !== null); + } + + public function offsetGet($offset): ?Node { + return $this->item($offset); + } + + public function offsetSet($offset, $value): void { + // Collections are immutable; the spec is ambiguous as to what to do here. + // Browsers silently fail here, so that's what we're going to do. + } + + public function offsetUnset($offset): void { + // Collections are immutable; the spec is ambiguous as to what to do here. + // Browsers silently fail here, so that's what we're going to do. + } + + public function valid(): bool { + return $this->offsetExists($this->position); + } +} \ No newline at end of file diff --git a/lib/Document.php b/lib/Document.php index 4eb6a31..941f2f7 100644 --- a/lib/Document.php +++ b/lib/Document.php @@ -95,35 +95,7 @@ class Document extends Node { $localName = strtolower($localName); } - // Before we do the next step we need to work around a PHP DOM bug. PHP DOM - // cannot create attribute nodes if there's no document element. So, create the - // attribute node in a separate document which does have a document element and - // then import - $target = $this->innerNode; - $documentElement = $this->documentElement; - if ($documentElement === null) { - $target = new \DOMDocument(); - $target->appendChild($target->createElement('html')); - } - - # 3. Return a new attribute whose local name is localName and node document is - # this. - // We need to do a couple more things here. PHP's XML-based DOM doesn't allow - // some characters. We have to coerce them sometimes. - try { - $attr = $target->createAttributeNS(null, $localName); - } catch (\DOMException $e) { - // The element name is invalid for XML - // Replace any offending characters with "UHHHHHH" where H are the - // uppercase hexadecimal digits of the character's code point - $attr = $target->createAttributeNS(null, $this->coerceName($localName)); - } - - if ($documentElement === null) { - return $this->importNode($attr); - } - - return $this->innerNode->getWrapperNode($attr); + return $this->__createAttribute(null, $localName); } public function createAttributeNS(?string $namespace, string $qualifiedName): Attr { @@ -134,35 +106,7 @@ class Document extends Node { [ 'namespace' => $namespace, 'prefix' => $prefix, 'localName' => $localName ] = $this->validateAndExtract($qualifiedName, $namespace); $qualifiedName = ($prefix) ? "$prefix:$localName" : $localName; - // Before we do the next step we need to work around a PHP DOM bug. PHP DOM - // cannot create attribute nodes if there's no document element. So, create the - // attribute node in a separate document which does have a document element and - // then import - $target = $this->innerNode; - $documentElement = $this->documentElement; - if ($documentElement === null) { - $target = new \DOMDocument(); - $target->appendChild($target->createElement('html')); - } - - # 2. Return a new attribute whose namespace is namespace, namespace prefix is - # prefix, local name is localName, and node document is this. - // We need to do a couple more things here. PHP's XML-based DOM doesn't allow - // some characters. We have to coerce them sometimes. - try { - $attr = $target->createAttributeNS($namespace, $qualifiedName); - } catch (\DOMException $e) { - // The element name is invalid for XML - // Replace any offending characters with "UHHHHHH" where H are the - // uppercase hexadecimal digits of the character's code point - $attr = $target->createAttributeNS($namespace, $this->coerceName($qualifiedName)); - } - - if ($documentElement === null) { - return $this->importNode($attr); - } - - return $this->innerNode->getWrapperNode($attr); + return $this->__createAttribute($namespace, $qualifiedName); } public function createCDATASection(string $data): CDATASection { @@ -284,17 +228,59 @@ class Document extends Node { } public function importNode(\DOMNode|Node $node, bool $deep = false): Node { - $isDOMNode = ($node instanceof \DOMNode); - $node = $this->innerNode->getWrapperNode($this->innerNode->importNode((!$isDOMNode) ? $this->getInnerNode($node) : $node, false)); - if ($node instanceof Element || $node instanceof DocumentFragment) { - $this->convertAdoptedOrImportedNode($node, $isDOMNode); + # The importNode(node, deep) method steps are: + # + # 1. If node is a document or shadow root, then throw a "NotSupportedError" + # DOMException. + if ($node instanceof Document || $node instanceof \DOMDocument) { + throw new DOMException(DOMException::NOT_SUPPORTED); } - return $node; + # 2. Return a clone of node, with this and the clone children flag set if deep + # is true. + // PHP's DOM mostly does this correctly. It, however, won't import doctypes... + if ($node instanceof DocumentType || $node instanceof \DOMDocumentType) { + return $this->implementation->createDocumentType($node->name, $node->publicId, $node->systemId); + } + + return $this->innerNode->getWrapperNode($this->innerNode->importNode((!$node instanceof \DOMNode) ? $this->getInnerNode($node) : $node, $deep)); } - protected function convertAdoptedOrImportedNode(Node $node, bool $originalWasDOMNode = false): Node { + protected function __createAttribute(?string $namespace, string $qualifiedName): Attr { + // Before we do the next step we need to work around a PHP DOM bug. PHP DOM + // cannot create attribute nodes if there's no document element. So, create the + // attribute node in a separate document which does have a document element and + // then import + $target = $this->innerNode; + $documentElement = $this->documentElement; + if ($documentElement === null) { + $target = new \DOMDocument(); + $target->appendChild($target->createElement('html')); + } + + // From createAttributeNS: + # 2. Return a new attribute whose namespace is namespace, namespace prefix is + # prefix, local name is localName, and node document is this. + // We need to do a couple more things here. PHP's XML-based DOM doesn't allow + // some characters. We have to coerce them sometimes. + try { + $attr = $target->createAttributeNS($namespace, $qualifiedName); + } catch (\DOMException $e) { + // The element name is invalid for XML + // Replace any offending characters with "UHHHHHH" where H are the + // uppercase hexadecimal digits of the character's code point + $attr = $target->createAttributeNS($namespace, $this->coerceName($qualifiedName)); + } + + if ($documentElement === null) { + return $this->importNode($attr); + } + + return $this->innerNode->getWrapperNode($attr); + } + + /*protected function convertAdoptedOrImportedNode(Node $node, bool $originalWasDOMNode = false): Node { // Yet another PHP DOM hang-up that is either a bug or a feature. When // elements are imported their id attributes aren't able to be picked up by // getElementById, so let's fix that. @@ -330,5 +316,5 @@ class Document extends Node { } return $node; - } + }*/ } \ No newline at end of file diff --git a/lib/Element.php b/lib/Element.php index 0af86e3..f332fe8 100644 --- a/lib/Element.php +++ b/lib/Element.php @@ -7,25 +7,19 @@ declare(strict_types=1); namespace MensBeam\HTML\DOM; -use MensBeam\HTML\DOM\InnerNode\Reflection, - MensBeam\HTML\Parser; +use MensBeam\HTML\DOM\InnerNode\{ + Document as InnerDocument, + Reflection +}; +use MensBeam\HTML\Parser; class Element extends Node { use ChildNode, DocumentOrElement, ParentNode; - protected function __get_attributes(): NodeList { - // NodeLists cannot be created from their constructors normally. - $doc = ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument; - return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NodeList', function() use($doc) { - $result = []; - $innerAttributes = $this->innerNode->attributes; - foreach ($innerAttributes as $i) { - $result[] = $doc->getWrapperNode($i); - } - - return $result; - }); + protected function __get_attributes(): NamedNodeMap { + // NamedNodeMaps cannot be created from their constructors normally. + return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NamedNodeMap', $this, ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument, $this->innerNode->attributes); } protected function __get_localName(): ?string { @@ -77,6 +71,43 @@ class Element extends Node { return $value; } + public function setAttribute(string $qualifiedName, string $value): void { + # 1. If qualifiedName does not match the Name production in XML, then throw an + # "InvalidCharacterError" DOMException. + if (preg_match(InnerDocument::NAME_PRODUCTION_REGEX, $qualifiedName) !== 1) { + throw new DOMException(DOMException::INVALID_CHARACTER); + } + + # 2. If this is in the HTML namespace and its node document is an HTML document, + # then set qualifiedName to qualifiedName in ASCII lowercase. + if (!$this instanceof XMLDocument) { + $qualifiedName = strtolower($qualifiedName); + } + + # 3. Let attribute be the first attribute in this’s attribute list whose + # qualified name is qualifiedName, and null otherwise. + # 4. If attribute is null, create an attribute whose local name is qualifiedName, + # value is value, and node document is this’s node document, then append this + # attribute to this, and then return. + # 5. Change attribute to value. + // Going to try to handle this by getting the PHP DOM to do the heavy lifting + // when we can because it's faster. + try { + $this->innerNode->setAttributeNS(null, $qualifiedName, $value); + } catch (\DOMException $e) { + // The attribute name is invalid for XML + // Replace any offending characters with "UHHHHHH" where H are the uppercase + // hexadecimal digits of the character's code point + $this->innerNode->setAttributeNS(null, $this->coerceName($qualifiedName), $value); + } + + // If you create an id attribute this way it won't be used by PHP in + // getElementById, so let's fix that. + if ($qualifiedName === 'id') { + $this->innerNode->setIdAttribute($qualifiedName, true); + } + } + public function setAttributeNode(Attr $attr): ?Attr { # The setAttributeNode(attr) and setAttributeNodeNS(attr) methods steps are to # return the result of setting an attribute given attr and this. @@ -87,4 +118,50 @@ class Element extends Node { public function setAttributeNodeNS(Attr $attr): ?Attr { return $this->setAttributeNode($attr); } + + public function setAttributeNS(?string $namespace, string $qualifiedName, string $value): void { + # 1. Let namespace, prefix, and localName be the result of passing namespace and + # qualifiedName to validate and extract. + [ 'namespace' => $namespace, 'prefix' => $prefix, 'localName' => $localName ] = $this->validateAndExtract($qualifiedName, $namespace); + $qualifiedName = ($prefix === null || $prefix === '') ? $localName : "{$prefix}:{$localName}"; + + # 2. Set an attribute value for this using localName, value, and also prefix and + # namespace. + // Going to try to handle this by getting the PHP DOM to do the heavy lifting + // when we can because it's faster. + // NOTE: We create attribute nodes so that xmlns attributes + // don't get lost; otherwise they cannot be serialized + if ($namespace === Parser::XMLNS_NAMESPACE) { + // Xmlns attributes have special bugs just for them. How lucky! Xmlns attribute + // nodes won't stick and can actually cause segmentation faults if created on a + // no longer existing document element, appended to another element, and then + // retrieved. So, use the methods used in Document::createAttributeNS to get an + // attribute node. + $a = $this->ownerDocument->createAttributeNS($namespace, $qualifiedName); + $a->value = $this->escapeString($value, true); + $this->setAttributeNodeNS($a); + } else { + try { + $this->innerNode->setAttributeNS($namespace, $qualifiedName, $value); + } catch (\DOMException $e) { + // The attribute name is invalid for XML + // Replace any offending characters with "UHHHHHH" where H are the + // uppercase hexadecimal digits of the character's code point + if ($namespace !== null) { + $qualifiedName = implode(':', array_map([$this, 'coerceName'], explode(':', $qualifiedName, 2))); + } else { + $qualifiedName = $this->coerceName($qualifiedName); + } + $this->innerNode->setAttributeNS($namespace, $qualifiedName, $value); + } + } + + if ($namespace === null) { + // If you create an id attribute this way it won't be used by PHP in + // getElementById, so let's fix that. + if ($qualifiedName === 'id') { + $this->innerNode->setIdAttribute($qualifiedName, true); + } + } + } } \ No newline at end of file diff --git a/lib/HTMLCollection.php b/lib/HTMLCollection.php index 3e51a04..4748363 100644 --- a/lib/HTMLCollection.php +++ b/lib/HTMLCollection.php @@ -7,103 +7,17 @@ declare(strict_types=1); namespace MensBeam\HTML\DOM; -use MensBeam\Framework\MagicProperties, - MensBeam\HTML\Parser; +use MensBeam\HTML\Parser; # An HTMLCollection object is a collection of elements. -# -# NOTE: HTMLCollection is a historical artifact we cannot rid the web of. While -# developers are of course welcome to keep using it, new API standard designers -# ought not to use it (use sequence in IDL instead). -# -# A collection is an object that represents a list of nodes. A collection can be -# either live or static. Unless otherwise stated, a collection must be live. -# -# If a collection is live, then the attributes and methods on that object must -# operate on the actual underlying data, not a snapshot of the data. -# -# When a collection is created, a filter and a root are associated with it. -# -# The collection then represents a view of the subtree rooted at the -# collection’s root, containing only nodes that match the given filter. The view -# is linear. In the absence of specific requirements to the contrary, the nodes -# within the collection must be sorted in tree order. -class HTMLCollection implements \ArrayAccess, \Countable, \Iterator { - use MagicProperties; - - protected ?\Closure $filter = null; - protected int $_length = 0; - protected ?array $nodeArray = null; - protected int $position = 0; - - - protected function __get_length(): int { - # The length attribute must return the number of nodes represented by the - # collection. - return $this->count(); - } - - - protected function __construct(array|\Closure $arrayOrClosure = []) { - // In this implementation the root part of the creation is handled either before - // the NodeList is created (array) or within the filter (\Closure). - if ($arrayOrClosure === null) { - $arrayOrClosure = []; - } - - if (is_callable($arrayOrClosure)) { - $this->filter = $arrayOrClosure; - } else { - // Check types while also unpacking the iterable. - $array = []; - foreach ($arrayOrClosure as $i) { - if (!$i instanceof Element) { - $type = gettype($i); - if ($type === 'object') { - $type = get_class($i); - } - throw new Exception(Exception::ARGUMENT_TYPE_ERROR, 1, 'arrayOrClosure', 'array|\\Closure>', $type); - } - - $array[] = $i; - } - - $this->nodeArray = $array; - $this->_length = count($array); - } - } - - public function count(): int { - if ($this->nodeArray !== null) { - return $this->_length; - } - - $nodeArray = ($this->filter)(); - return count($nodeArray); - } - +class HTMLCollection extends Collection { public function current(): ?Element { - return $this->item($this->position); + return parent::current(); } public function item(int $index): ?Element { - # The item(index) method must return the indexth node in the collection. If - # there is no indexth node in the collection, then the method must return null. - if ($index >= $this->count()) { - return null; - } - - $nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)(); - if (array_key_exists($index, $nodeArray)) { - return $nodeArray[$index]; - } - - return null; - } - - public function key(): int { - return $this->position; + return parent::item($index); } public function namedItem(string $name): ?Element { @@ -119,48 +33,16 @@ class HTMLCollection implements \ArrayAccess, \Countable, \Iterator { # • it has an ID which is key; # • it is in the HTML namespace and has a name attribute whose value is key; # or null if there is no such element. - $nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)(); - foreach ($nodeArray as $element) { - if ($element->getAttribute('id') === $name || $element->namespaceURI === Parser::HTML_NAMESPACE && $element->getAttribute('name') === $name) { - return $element; + foreach ($this->innerCollection as $element) { + if ($element->getAttribute('id') === $name || $element->namespaceURI === null && $element->getAttribute('name') === $name) { + return $this->innerDocument->getWrapperNode($element); } } return null; } - public function next(): void { - $this->position++; - } - - public function rewind(): void { - $this->position = 0; - } - - public function offsetExists($offset): bool { - if (is_int($offset)) { - $nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)(); - return array_key_exists($offset, $nodeArray); - } - - return ($this->namedItem($offset) !== null); - } - public function offsetGet($offset): ?Element { return (is_int($offset)) ? $this->item($offset) : $this->namedItem($offset); } - - public function offsetSet($offset, $value): void { - // NodeLists are immutable; the spec is ambiguous as to what to do here. - // Browsers silently fail here, so that's what we're going to do. - } - - public function offsetUnset($offset): void { - // NodeLists are immutable; the spec is ambiguous as to what to do here. - // Browsers silently fail here, so that's what we're going to do. - } - - public function valid() { - $this->offsetExists($this->position); - } } \ No newline at end of file diff --git a/lib/HTMLTemplateElement.php b/lib/HTMLTemplateElement.php index 1c268fc..9fd7596 100644 --- a/lib/HTMLTemplateElement.php +++ b/lib/HTMLTemplateElement.php @@ -7,8 +7,7 @@ declare(strict_types=1); namespace MensBeam\HTML\DOM; -use MensBeam\HTML\DOM\InnerNode\Element as InnerElement, - MensBeam\HTML\DOM\InnerNode\Reflection; +use MensBeam\HTML\DOM\InnerNode\Reflection; class HTMLTemplateElement extends HTMLElement { @@ -21,7 +20,7 @@ class HTMLTemplateElement extends HTMLElement { } - protected function __construct(InnerElement $element) { + protected function __construct(\DOMElement $element) { parent::__construct($element); $this->_content = $this->ownerDocument->createDocumentFragment(); diff --git a/lib/InnerNode/Document.php b/lib/InnerNode/Document.php index 6aca8e7..0cb1b6d 100644 --- a/lib/InnerNode/Document.php +++ b/lib/InnerNode/Document.php @@ -19,6 +19,8 @@ use MensBeam\HTML\Parser; class Document extends \DOMDocument { use MagicProperties; + // Used for validation. Not sure where to put them where they wouldn't be + // exposed unnecessarily to the public API. public const NAME_PRODUCTION_REGEX = '/^[:A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][:A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]*$/Su'; public const QNAME_PRODUCTION_REGEX = '/^([A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]*:)?[A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]*$/Su'; @@ -104,7 +106,11 @@ class Document extends \DOMDocument { Reflection::setProtectedProperties($wrapperNode, [ '_ownerDocument' => $this->_wrapperNode ]); } - $this->nodeMap->set($wrapperNode, $node); + // Don't put documents into the node map cache to prevent circular references. + if ($className !== 'Document') { + $this->nodeMap->set($wrapperNode, $node); + } + return $wrapperNode; } } diff --git a/lib/NamedNodeMap.php b/lib/NamedNodeMap.php new file mode 100644 index 0000000..98ea88d --- /dev/null +++ b/lib/NamedNodeMap.php @@ -0,0 +1,76 @@ +element = $element; + $this->innerDocument = $innerDocument; + $this->innerCollection = $namedNodeMap; + } + + + public function current(): ?Attr { + return parent::current(); + } + + public function getNamedItem(string $qualifiedName): ?Attr { + # The getNamedItem(qualifiedName) method steps are to return the result of + # getting an attribute given qualifiedName and element. + return $this->element->getAttributeNode($qualifiedName); + } + + public function getNamedItemNS(?string $namespace, string $localName): ?Attr { + # The getNamedItemNS(namespace, localName) method steps are to return the result + # of getting an attribute given namespace, localName, and element. + return $this->element->getAttributeNodeNS($namespace, $localName); + } + + public function item(int $index): ?Attr { + return parent::item($index); + } + + public function removeNamedItem(string $qualifiedName): ?Attr { + return $this->removeNamedItemNS(null, $qualifiedName); + } + + public function removeNamedItemNS(?string $namespace, string $localName): ?Attr { + # The removeNamedItem(qualifiedName) method steps are: + # + # 1. Let attr be the result of removing an attribute given namespace, localName, + # and element. + $attr = $this->element->removeAttributeNode($namespace, $localName); + + # 2. If attr is null, then throw a "NotFoundError" DOMException. + if ($attr === null) { + throw new DOMException(DOMException::NOT_FOUND); + } + + # 3. Return attr. + return $attr; + } + + public function setNamedItem(string $attr): ?Attr { + # The setNamedItem(attr) and setNamedItemNS(attr) method steps are to return the + # result of setting an attribute given attr and element. + return $this->element->setAttributeNode($attr); + } + + public function setNamedItemNS(string $attr): ?Attr { + # The setNamedItem(attr) and setNamedItemNS(attr) method steps are to return the + # result of setting an attribute given attr and element. + return $this->element->setAttributeNode($attr); + } +} \ No newline at end of file diff --git a/lib/Node.php b/lib/Node.php index 8b679c5..0b1e048 100644 --- a/lib/Node.php +++ b/lib/Node.php @@ -35,7 +35,6 @@ abstract class Node { public const DOCUMENT_POSITION_CONTAINED_BY = 0x10; public const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 0x20; - protected ?NodeList $_childNodes = null; protected \DOMNode $innerNode; private static ?int $rand = null; @@ -73,29 +72,7 @@ abstract class Node { } protected function __get_childNodes(): NodeList { - // NodeLists cannot be created from their constructors normally. - // OPTIMIZATION: Going to optimize here and only create a truly live NodeList if - // the node is even capable of having children, otherwise will just be an empty - // NodeList. There is no sense in generating a live list that will never update. - if ($this instanceof Document || $this instanceof DocumentFragment || $this instanceof Element) { - $doc = ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument; - return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NodeList', function() use($doc) { - $result = []; - $innerChildNodes = $this->innerNode->childNodes; - foreach ($innerChildNodes as $i) { - $result[] = $doc->getWrapperNode($i); - } - - return $result; - }); - } - - if ($this->_childNodes !== null) { - return $this->_childNodes; - } - - return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\Nodelist', []); - return $this->_childNodes; + return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\NodeList', ($this instanceof Document) ? $this->innerNode : $this->innerNode->ownerDocument, $this->innerNode->childNodes); } protected function __get_firstChild(): ?Node { @@ -323,6 +300,10 @@ abstract class Node { public function cloneNode(?bool $deep = false): Node { # The cloneNode(deep) method steps are: + // PHP's DOM mostly does this correctly with the exception of not cloning + // doctypes. However, the entire process needs to be done manually because of + // templates. + # 1. If this is a shadow root, then throw a "NotSupportedError" DOMException. // DEVIATION: There is no scripting in this implementation @@ -335,16 +316,14 @@ abstract class Node { // No need for this step. There will always be a provided document # 2. If node is an element, then: - if ($this instanceof Element) { - # 1. Let copy be the result of creating an element, given document, node’s local - # name, node’s namespace, node’s namespace prefix, and node’s is value, with the - # synchronous custom elements flag unset. - # 2. For each attribute in node’s attribute list: - # 1. Let copyAttribute be a clone of attribute. - # 2. Append copyAttribute to copy. - // PHP's DOM can do this part correctly by shallow cloning. - $copy = $this->innerNode->ownerDocument->getWrapperNode($this->innerNode->cloneNode()); - } + # 1. Let copy be the result of creating an element, given document, node’s local + # name, node’s namespace, node’s namespace prefix, and node’s is value, with the + # synchronous custom elements flag unset. + # 2. For each attribute in node’s attribute list: + # 1. Let copyAttribute be a clone of attribute. + # 2. Append copyAttribute to copy. + // PHP's DOM can do this part correctly by shallow cloning, so it will be + // handled instead in the "Otherwise" section of step #3. # 3. Otherwise, let copy be a node that implements the same interfaces as node, and # fulfills these additional requirements, switching on the interface node @@ -352,7 +331,7 @@ abstract class Node { # # ↪ Document # Set copy’s encoding, content type, URL, origin, type, and mode to those of node. - elseif ($this instanceof Document) { + if ($this instanceof Document) { $copy = $this->innerNode->getWrapperNode($this->innerNode->cloneNode()); if ($this->characterSet !== 'UTF-8' || $this->compatMode !== 'CSS1Compat' || $this->contentType !== 'text/html' || $this->URL !== '') { @@ -410,7 +389,7 @@ abstract class Node { # contents, with document set to copy's template contents's node document, and # with the clone children flag set. # 3. Append copied contents to copy's template contents. - $copy->content = $this->content->cloneNode(true); + $copy->content->appendChild($this->content->cloneNode(true)); } # 6. If the clone children flag is set, clone all the children of node and append @@ -419,12 +398,11 @@ abstract class Node { if ($this instanceof Document) { $childNodes = $this->childNodes; foreach ($childNodes as $child) { - $copy->appendChild($child->importNode(true)); + $copy->appendChild($copy->importNode($child, true)); } } elseif ($this instanceof DocumentFragment || $this instanceof Element) { $childNodes = $this->childNodes; foreach ($childNodes as $child) { - var_export($child); $copy->appendChild($child->cloneNode(true)); } } @@ -639,7 +617,7 @@ abstract class Node { # • Each child of A equals the child of B at the identical index. foreach ($this->childNodes as $key => $child) { $other = $otherNode->childNodes[$key]; - if ($child->name !== $other->name || $child->publicId !== $other->publicId || $child->systemId !== $other->systemId) { + if (!$child->isEqualNode($other)) { return false; } } diff --git a/lib/NodeList.php b/lib/NodeList.php index 84cd8de..a5372cb 100644 --- a/lib/NodeList.php +++ b/lib/NodeList.php @@ -7,128 +7,7 @@ declare(strict_types=1); namespace MensBeam\HTML\DOM; -use MensBeam\Framework\MagicProperties; # A NodeList object is a collection of nodes. -# -# A collection is an object that represents a list of nodes. A collection can be -# either live or static. Unless otherwise stated, a collection must be live. -# -# If a collection is live, then the attributes and methods on that object must -# operate on the actual underlying data, not a snapshot of the data. -# -# When a collection is created, a filter and a root are associated with it. -# -# The collection then represents a view of the subtree rooted at the -# collection’s root, containing only nodes that match the given filter. The view -# is linear. In the absence of specific requirements to the contrary, the nodes -# within the collection must be sorted in tree order. -class NodeList implements \ArrayAccess, \Countable, \Iterator { - use MagicProperties; - - protected ?\Closure $filter = null; - protected int $_length = 0; - protected ?array $nodeArray = null; - protected int $position = 0; - - - protected function __get_length(): int { - # The length attribute must return the number of nodes represented by the - # collection. - return $this->count(); - } - - - protected function __construct(array|\Closure $arrayOrClosure = []) { - // In this implementation the root part of the creation is handled either before - // the NodeList is created (array) or within the filter (\Closure). - if ($arrayOrClosure === null) { - $arrayOrClosure = []; - } - - if (is_callable($arrayOrClosure)) { - $this->filter = $arrayOrClosure; - } else { - // Check types while also unpacking the iterable. - $array = []; - foreach ($arrayOrClosure as $i) { - if (!$i instanceof Node) { - $type = gettype($i); - if ($type === 'object') { - $type = get_class($i); - } - throw new Exception(Exception::ARGUMENT_TYPE_ERROR, 1, 'arrayOrClosure', 'array|\\Closure>', $type); - } - - $array[] = $i; - } - - $this->nodeArray = $array; - $this->_length = count($array); - } - } - - public function count(): int { - if ($this->nodeArray !== null) { - return $this->_length; - } - - $nodeArray = ($this->filter)(); - return count($nodeArray); - } - - public function current(): ?Node { - return $this->item($this->position); - } - - public function item(int $index): ?Node { - # The item(index) method must return the indexth node in the collection. If - # there is no indexth node in the collection, then the method must return null. - if ($index >= $this->count()) { - return null; - } - - $nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)(); - if (array_key_exists($index, $nodeArray)) { - return $nodeArray[$index]; - } - - return null; - } - - public function key(): int { - return $this->position; - } - - public function next(): void { - $this->position++; - } - - public function rewind(): void { - $this->position = 0; - } - - public function offsetExists($offset): bool { - $nodeArray = ($this->nodeArray !== null) ? $this->nodeArray : ($this->filter)(); - return array_key_exists($offset, $nodeArray); - } - - public function offsetGet($offset): ?Node { - return $this->item($offset); - } - - public function offsetSet($offset, $value): void { - // NodeLists are immutable; the spec is ambiguous as to what to do here. - // Browsers silently fail here, so that's what we're going to do. - } - - public function offsetUnset($offset): void { - // NodeLists are immutable; the spec is ambiguous as to what to do here. - // Browsers silently fail here, so that's what we're going to do. - } - - public function valid() { - $this->offsetExists($this->position); - } -} \ No newline at end of file +class NodeList extends Collection {} \ No newline at end of file diff --git a/tests/cases/TestDocument.php b/tests/cases/TestDocument.php new file mode 100644 index 0000000..b7699dc --- /dev/null +++ b/tests/cases/TestDocument.php @@ -0,0 +1,52 @@ +appendChild($d->createElement('html')); + + // Node::body without body + $this->assertNull($d->body); + + $body = $d->documentElement->appendChild($d->createElement('body')); + + // Node::body with body + $this->assertSame($body, $d->body); + } +} \ No newline at end of file diff --git a/tests/cases/TestNode.php b/tests/cases/TestNode.php index db4db45..beb41e0 100644 --- a/tests/cases/TestNode.php +++ b/tests/cases/TestNode.php @@ -16,23 +16,35 @@ use MensBeam\HTML\DOM\{ use MensBeam\HTML\Parser; -/** @covers \MensBeam\HTML\DOM\Document */ +/** @covers \MensBeam\HTML\DOM\Node */ class TestNode extends \PHPUnit\Framework\TestCase { /** * @covers \MensBeam\HTML\DOM\Node::cloneNode * * @covers \MensBeam\HTML\DOM\Attr::__construct - * @covers \MensBeam\HTML\DOM\Comment::__construct * @covers \MensBeam\HTML\DOM\CDATASection::__construct + * @covers \MensBeam\HTML\DOM\Collection::count + * @covers \MensBeam\HTML\DOM\Collection::current + * @covers \MensBeam\HTML\DOM\Collection::__get_length + * @covers \MensBeam\HTML\DOM\Collection::item + * @covers \MensBeam\HTML\DOM\Collection::key + * @covers \MensBeam\HTML\DOM\Collection::next + * @covers \MensBeam\HTML\DOM\NamedNodeMap::offsetGet + * @covers \MensBeam\HTML\DOM\Collection::offsetExists + * @covers \MensBeam\HTML\DOM\Collection::rewind + * @covers \MensBeam\HTML\DOM\Collection::valid + * @covers \MensBeam\HTML\DOM\Comment::__construct * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::__get_body * @covers \MensBeam\HTML\DOM\Document::createAttribute - * @covers \MensBeam\HTML\DOM\Document::createAttributeNS + * @covers \MensBeam\HTML\DOM\Document::__createAttribute * @covers \MensBeam\HTML\DOM\Document::createComment * @covers \MensBeam\HTML\DOM\Document::createDocumentFragment * @covers \MensBeam\HTML\DOM\Document::createElement * @covers \MensBeam\HTML\DOM\Document::createElementNS * @covers \MensBeam\HTML\DOM\Document::createProcessingInstruction * @covers \MensBeam\HTML\DOM\Document::createTextNode + * @covers \MensBeam\HTML\DOM\Document::importNode * @covers \MensBeam\HTML\DOM\DocumentOrElement::validateAndExtract * @covers \MensBeam\HTML\DOM\DocumentFragment::__construct * @covers \MensBeam\HTML\DOM\DocumentType::__construct @@ -40,6 +52,9 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct * @covers \MensBeam\HTML\DOM\DOMImplementation::createDocumentType * @covers \MensBeam\HTML\DOM\Element::__construct + * @covers \MensBeam\HTML\DOM\NamedNodeMap::__construct + * @covers \MensBeam\HTML\DOM\NamedNodeMap::current + * @covers \MensBeam\HTML\DOM\NamedNodeMap::item * @covers \MensBeam\HTML\DOM\Node::__construct * @covers \MensBeam\HTML\DOM\Node::isEqualNode * @covers \MensBeam\HTML\DOM\ProcessingInstruction::__construct @@ -59,12 +74,15 @@ class TestNode extends \PHPUnit\Framework\TestCase { $d = new Document(); $d2 = new XMLDocument(); $doctype = $d->appendChild($d->implementation->createDocumentType('html', '', '')); + $element = $d->appendChild($d->createElement('html')); + $element->appendChild($d->createElement('body')); + $d->body->setAttribute('id', 'ook'); + $template = $d->body->appendChild($d->createElement('template')); + $template->content->appendChild($d->createTextNode('ook')); $attr = $d->createAttribute('href'); $attr->value = 'https://poop💩.poop'; $cdata = $d2->createCDATASection('ook'); $comment = $d->createComment('comment'); - $element = $d->createElement('html'); - $element->appendChild($d->createElement('body')); $pi = $d->createProcessingInstruction('ook', 'eek'); $text = $d->createTextNode('ook'); $frag = $d->createDocumentFragment(); @@ -88,8 +106,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { // Node::cloneNode on document $dClone = $d->cloneNode(true); $this->assertNotSame($dClone, $d); - // Children on documents aren't cloned - $this->assertFalse($dClone->isEqualNode($d)); + $this->assertTrue($dClone->isEqualNode($d)); // Node::cloneNode on doctype $doctypeClone = $doctype->cloneNode(); @@ -99,45 +116,35 @@ class TestNode extends \PHPUnit\Framework\TestCase { // Node::cloneNode on document fragment $fragClone = $frag->cloneNode(true); $this->assertNotSame($fragClone, $frag); - // Children on document fragments aren't cloned - $this->assertFalse($fragClone->isEqualNode($frag)); + $this->assertTrue($fragClone->isEqualNode($frag)); // Node::cloneNode on element $elementClone = $element->cloneNode(true); $this->assertNotSame($elementClone, $element); - // Children on documents aren't cloned $this->assertTrue($elementClone->isEqualNode($element)); - /* - - // Node::nodeType on comment - $this->assertSame($d, $d->createComment('comment')->ownerDocument); - - // Node::nodeType on document - $this->assertNull($d->ownerDocument); - - // Node::nodeType on doctype - $this->assertSame($d, $d->implementation->createDocumentType('html', '', '')->ownerDocument); + // Node::cloneNode on processing instruction + $piClone = $pi->cloneNode(); + $this->assertNotSame($piClone, $pi); + $this->assertTrue($piClone->isEqualNode($pi)); - // Node::nodeType on document fragment - $this->assertSame($d, $d->createDocumentFragment()->ownerDocument); - - // Node::nodeType on element - $this->assertSame($d, $d->createElement('html')->ownerDocument); - - // Node::nodeType on processing instruction - $this->assertSame($d, $d->createProcessingInstruction('ook', 'eek')->ownerDocument); - - // Node::nodeType on text node - $this->assertSame($d, $d->createTextNode('ook')->ownerDocument); - */ + // Node::cloneNode on text node + $textClone = $text->cloneNode(); + $this->assertNotSame($textClone, $text); + $this->assertTrue($textClone->isEqualNode($text)); } /** * @covers \MensBeam\HTML\DOM\Node::__get_childNodes * + * @covers \MensBeam\HTML\DOM\Collection::__construct + * @covers \MensBeam\HTML\DOM\Collection::count + * @covers \MensBeam\HTML\DOM\Collection::__get_length + * @covers \MensBeam\HTML\DOM\Collection::item + * @covers \MensBeam\HTML\DOM\Collection::offsetGet * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::__get_body * @covers \MensBeam\HTML\DOM\Document::createElement * @covers \MensBeam\HTML\DOM\Document::createTextNode * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct @@ -145,11 +152,6 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\Node::__construct * @covers \MensBeam\HTML\DOM\Node::appendChild * @covers \MensBeam\HTML\DOM\Node::preInsertionValidity - * @covers \MensBeam\HTML\DOM\NodeList::__construct - * @covers \MensBeam\HTML\DOM\NodeList::count - * @covers \MensBeam\HTML\DOM\NodeList::__get_length - * @covers \MensBeam\HTML\DOM\NodeList::item - * @covers \MensBeam\HTML\DOM\NodeList::offsetGet * @covers \MensBeam\HTML\DOM\Text::__construct * @covers \MensBeam\HTML\DOM\InnerNode\Document::__construct * @covers \MensBeam\HTML\DOM\InnerNode\Document::getWrapperNode @@ -186,6 +188,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\Node::__get_firstChild * * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::__get_body * @covers \MensBeam\HTML\DOM\Document::createElement * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct * @covers \MensBeam\HTML\DOM\Element::__construct @@ -253,6 +256,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\Node::__get_lastChild * * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::__get_body * @covers \MensBeam\HTML\DOM\Document::createElement * @covers \MensBeam\HTML\DOM\Document::createTextNode * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct @@ -295,6 +299,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\Node::__get_previousSibling * * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::__get_body * @covers \MensBeam\HTML\DOM\Document::createElement * @covers \MensBeam\HTML\DOM\Document::createTextNode * @covers \MensBeam\HTML\DOM\DocumentType::__construct @@ -339,6 +344,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\Node::__get_nextSibling * * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::__get_body * @covers \MensBeam\HTML\DOM\Document::createElement * @covers \MensBeam\HTML\DOM\Document::createTextNode * @covers \MensBeam\HTML\DOM\DocumentType::__construct @@ -387,6 +393,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\CDATASection::__construct * @covers \MensBeam\HTML\DOM\Document::__construct * @covers \MensBeam\HTML\DOM\Document::createAttribute + * @covers \MensBeam\HTML\DOM\Document::__createAttribute * @covers \MensBeam\HTML\DOM\Document::createAttributeNS * @covers \MensBeam\HTML\DOM\Document::createComment * @covers \MensBeam\HTML\DOM\Document::createDocumentFragment @@ -470,7 +477,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\CDATASection::__construct * @covers \MensBeam\HTML\DOM\Document::__construct * @covers \MensBeam\HTML\DOM\Document::createAttribute - * @covers \MensBeam\HTML\DOM\Document::createAttributeNS + * @covers \MensBeam\HTML\DOM\Document::__createAttribute * @covers \MensBeam\HTML\DOM\Document::createComment * @covers \MensBeam\HTML\DOM\Document::createDocumentFragment * @covers \MensBeam\HTML\DOM\Document::createElement @@ -531,7 +538,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\CDATASection::__construct * @covers \MensBeam\HTML\DOM\Document::__construct * @covers \MensBeam\HTML\DOM\Document::createAttribute - * @covers \MensBeam\HTML\DOM\Document::createAttributeNS + * @covers \MensBeam\HTML\DOM\Document::__createAttribute * @covers \MensBeam\HTML\DOM\Document::createComment * @covers \MensBeam\HTML\DOM\Document::createDocumentFragment * @covers \MensBeam\HTML\DOM\Document::createElement @@ -619,7 +626,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\CDATASection::__construct * @covers \MensBeam\HTML\DOM\Document::__construct * @covers \MensBeam\HTML\DOM\Document::createAttribute - * @covers \MensBeam\HTML\DOM\Document::createAttributeNS + * @covers \MensBeam\HTML\DOM\Document::__createAttribute * @covers \MensBeam\HTML\DOM\Document::createComment * @covers \MensBeam\HTML\DOM\Document::createDocumentFragment * @covers \MensBeam\HTML\DOM\Document::createElement @@ -680,8 +687,9 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\Comment::__construct * @covers \MensBeam\HTML\DOM\CDATASection::__construct * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::__get_body * @covers \MensBeam\HTML\DOM\Document::createAttribute - * @covers \MensBeam\HTML\DOM\Document::createAttributeNS + * @covers \MensBeam\HTML\DOM\Document::__createAttribute * @covers \MensBeam\HTML\DOM\Document::createComment * @covers \MensBeam\HTML\DOM\Document::createDocumentFragment * @covers \MensBeam\HTML\DOM\Document::createElement @@ -758,7 +766,7 @@ class TestNode extends \PHPUnit\Framework\TestCase { * @covers \MensBeam\HTML\DOM\CDATASection::__construct * @covers \MensBeam\HTML\DOM\Document::__construct * @covers \MensBeam\HTML\DOM\Document::createAttribute - * @covers \MensBeam\HTML\DOM\Document::createAttributeNS + * @covers \MensBeam\HTML\DOM\Document::__createAttribute * @covers \MensBeam\HTML\DOM\Document::createComment * @covers \MensBeam\HTML\DOM\Document::createDocumentFragment * @covers \MensBeam\HTML\DOM\Document::createElement diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml index 8b4abe8..34d64e5 100644 --- a/tests/phpunit.dist.xml +++ b/tests/phpunit.dist.xml @@ -16,6 +16,7 @@ + cases/TestDocument.php cases/TestNode.php