From b00c7f6e47a3169a515e6e67f9aa593aefea4325 Mon Sep 17 00:00:00 2001 From: Dustin Wilson Date: Tue, 12 Oct 2021 11:46:05 -0500 Subject: [PATCH] Wrangling with class lists --- lib/Element.php | 230 ++++++++++++++++++++++++------------ lib/TokenList.php | 5 +- tests/cases/TestElement.php | 38 +++++- 3 files changed, 195 insertions(+), 78 deletions(-) diff --git a/lib/Element.php b/lib/Element.php index 808b04a..16b229c 100644 --- a/lib/Element.php +++ b/lib/Element.php @@ -14,8 +14,8 @@ use MensBeam\HTML\Parser; class Element extends \DOMElement { use ChildNode, DocumentOrElement, MagicProperties, Moonwalk, ParentNode, ToString, Walk; - protected ?TokenList $_classList = null; + protected ?TokenList $_classList = null; protected function __get_classList(): TokenList { // Only create the class list if it is actually used. @@ -153,7 +153,7 @@ class Element extends \DOMElement { # The getAttribute(qualifiedName) method steps are: # # 1. Let attr be the result of getting an attribute given qualifiedName and this. - $attr = $this->getAttributeNode($qualifiedName); + $attr = $this->_getAttributeNode($qualifiedName); # 2. If attr is null, return null. if ($attr === null) { return null; @@ -173,83 +173,44 @@ class Element extends \DOMElement { return $result; } - public function getAttributeNode(string $qualifiedName): ?\DOMAttr { + public function getAttributeNode(string $qualifiedName): ?Attr { # The getAttributeNode(qualifiedName) method steps are to return the result of # getting an attribute given qualifiedName and this. - # - # To get an attribute by name given a qualifiedName and element element, run - # these steps: - # - # 1. If element is in the HTML namespace and its node document is an HTML document, - # then set qualifiedName to qualifiedName in ASCII lowercase. - // Document will always be an HTML document - if ($this->isHTMLNamespace()) { - $qualifiedName = strtolower($qualifiedName); - } - - # 2. Return the first attribute in element’s attribute list whose qualified name is - # qualifiedName; otherwise null. - // Going to try to handle this by getting the PHP DOM to do the heavy lifting - // when we can because it's faster. - $value = parent::getAttributeNode($qualifiedName); - if ($value === false) { - // Replace any offending characters with "UHHHHHH" where H are the uppercase - // hexadecimal digits of the character's code point - $qualifiedName = $this->coerceName($qualifiedName); - - foreach ($this->attributes as $a) { - if ($a->nodeName === $qualifiedName) { - return $a; - } - } - return null; + $result = $this->_getAttributeNode($qualifiedName); + // More classlist bullshit. Since we cannot extend \DOMAttr in a way that will + // allow us to set the classList if a class attribute's value is modified we + // will instead remove the classList and force it to be recreated when a class + // attribute is requested. + if ($result !== null && $result->name === 'class') { + $this->_classList = null; } - return ($value !== false) ? $value : null; + return $result; } - public function getAttributeNodeNS(?string $namespace = null, string $localName): ?\DOMAttr { + public function getAttributeNodeNS(?string $namespace = null, string $localName): ?Attr { # The getAttributeNodeNS(namespace, localName) method steps are to return the # result of getting an attribute given namespace, localName, and this. - # - # To get an attribute by namespace and local name given a namespace, localName, - # and element element, run these steps: - # - # 1. If namespace is the empty string, then set it to null. - if ($namespace === '') { - $namespace = null; + $result = $this->_getAttributeNodeNS($namespace, $localName); + // More classlist bullshit. Since we cannot extend \DOMAttr in a way that will + // allow us to set the classList if a class attribute's value is modified we + // will instead remove the classList and force it to be recreated when a class + // attribute is requested. + if ($result !== null && $result->name === 'class') { + $this->_classList = null; + ElementMap::delete($this); } - # 2. Return the attribute in element’s attribute list whose namespace is namespace - # and local name is localName, if any; otherwise null. - // Going to try to handle this by getting the PHP DOM to do the heavy lifting - // when we can because it's faster. - $value = parent::getAttributeNodeNS($namespace, $localName); - if (!$value) { - // Replace any offending characters with "UHHHHHH" where H are the uppercase - // hexadecimal digits of the character's code point - $namespace = $this->coerceName($namespace ?? ''); - $localName = $this->coerceName($localName); - - // The PHP DOM does not acknowledge the presence of XMLNS-namespace attributes - // sometimes, too... so this will get those as well in those circumstances. - foreach ($this->attributes as $a) { - if ($a->namespaceURI === $namespace && $a->localName === $localName) { - return $a; - } - } - return null; - } - - return ($value !== false) ? $value : null; + return $result; } + public function getAttributeNS(?string $namespace = null, string $localName): ?string { # The getAttributeNS(namespace, localName) method steps are: # # 1. Let attr be the result of getting an attribute given namespace, localName, # and this. - $attr = $this->getAttributeNodeNS($namespace, $localName); + $attr = $this->_getAttributeNodeNS($namespace, $localName); # 2. If attr is null, return null. if ($attr === null) { @@ -281,7 +242,7 @@ class Element extends \DOMElement { // The PHP DOM does not acknowledge the presence of XMLNS-namespace attributes, // so try it again just in case; getAttributeNode will coerce names if // necessary, too. - $value = ($this->getAttributeNode($qualifiedName) !== null); + $value = ($this->_getAttributeNode($qualifiedName) !== null); } return $value; @@ -305,12 +266,55 @@ class Element extends \DOMElement { // The PHP DOM does not acknowledge the presence of XMLNS-namespace attributes, // so try it again just in case; getAttributeNode will coerce names if // necessary, too. - $value = ($this->getAttributeNodeNS($namespace, $localName) !== null); + $value = ($this->_getAttributeNodeNS($namespace, $localName) !== null); } return $value; } + public function removeAttribute(string $qualifiedName): void { + # The removeAttribute(qualifiedName) method steps are to remove an attribute + # given qualifiedName and this, and then return undefined. + # + ## To remove an attribute by name given a qualifiedName and element element, run + ## these steps: + ## + ## 1. Let attr be the result of getting an attribute given qualifiedName and element. + $attr = $this->_getAttributeNode($qualifiedName); + ## 2. If attr is non-null, then remove attr. + if ($attr !== null) { + // Going to try to handle this by getting the PHP DOM to do the heavy lifting + // when we can because it's faster. + parent::removeAttributeNode($attr); + + // ClassList stuff because php garbage collection is... garbage. + if ($qualifiedName === 'class' && $this->_classList !== null) { + $this->_classList = null; + ElementMap::delete($this); + } + } + ## 3. Return attr. + // Supposed to return undefined in the end, so let's skip this. + } + + public function removeAttributeNS(?string $namespace, string $localName): bool { + # The removeAttributeNS(namespace, localName) method steps are to remove an + # attribute given namespace, localName, and this, and then return undefined. + # + ## To remove an attribute by namespace and local name given a namespace, localName, and element element, run these steps: + ## + ## 1. Let attr be the result of getting an attribute given namespace, localName, and element. + $attr = $this->_getAttributeNodeNS($namespace, $localName); + ## 2. If attr is non-null, then remove attr. + if ($attr !== null) { + // Going to try to handle this by getting the PHP DOM to do the heavy lifting + // when we can because it's faster. + parent::removeAttributeNode($attr); + } + ## 3. Return attr. + // Supposed to return undefined in the end, so let's skip this. + } + public function setAttribute(string $qualifiedName, string $value): void { # 1. If qualifiedName does not match the Name production in XML, then throw an # "InvalidCharacterError" DOMException. @@ -324,6 +328,7 @@ class Element extends \DOMElement { if ($this->isHTMLNamespace()) { $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, @@ -331,14 +336,15 @@ class Element extends \DOMElement { # 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. But, first, we have to hack in classList - // support and then work around a couple of PHP bugs. - if ($this->isHTMLNamespace()) { - $qualifiedName = strtolower($qualifiedName); - - if ($qualifiedName === 'class' && $this->_classList !== null) { + // when we can because it's faster. But, first, we must work around PHP's + // garbage garbage collection. + if ($qualifiedName === 'class' && $this->_classList !== null) { + if ($value !== '') { $this->_classList->value = $value; return; + } else { + $this->_classList = null; + ElementMap::delete($this); } } @@ -367,11 +373,19 @@ class Element extends \DOMElement { # 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. But, first, we have to hack in classList - // support and then work around a couple of PHP bugs. - if ($this->isHTMLNamespace() && $qualifiedName === 'class' && $this->_classList !== null) { - $this->_classList->value = $value; - } elseif ($namespace === Parser::XMLNS_NAMESPACE) { + // when we can because it's faster. But, first, we must work around a couple of + // PHP bugs and its garbage garbage collection. + if ($qualifiedName === 'class' && $this->_classList !== null) { + if ($value !== '') { + $this->_classList->value = $value; + return; + } else { + $this->_classList = null; + ElementMap::delete($this); + } + } + + if ($namespace === Parser::XMLNS_NAMESPACE) { // NOTE: We create attribute nodes so that xmlns attributes // don't get lost; otherwise they cannot be serialized $a = @$this->ownerDocument->createAttributeNS($namespace, $qualifiedName); @@ -404,4 +418,70 @@ class Element extends \DOMElement { $this->setIdAttribute($qualifiedName, true); } } + + + protected function _getAttributeNode(string $qualifiedName): ?Attr { + # To get an attribute by name given a qualifiedName and element element, run + # these steps: + # + # 1. If element is in the HTML namespace and its node document is an HTML document, + # then set qualifiedName to qualifiedName in ASCII lowercase. + // Document will always be an HTML document + if ($this->isHTMLNamespace()) { + $qualifiedName = strtolower($qualifiedName); + } + + # 2. Return the first attribute in element’s attribute list whose qualified name is + # qualifiedName; otherwise null. + // Going to try to handle this by getting the PHP DOM to do the heavy lifting + // when we can because it's faster. + $attr = parent::getAttributeNode($qualifiedName); + if ($attr === false) { + // Replace any offending characters with "UHHHHHH" where H are the uppercase + // hexadecimal digits of the character's code point + $qualifiedName = $this->coerceName($qualifiedName); + + foreach ($this->attributes as $a) { + if ($a->nodeName === $qualifiedName) { + return $a; + } + } + return null; + } + + return ($attr !== false) ? $attr : null; + } + + protected function _getAttributeNodeNS(?string $namespace = null, string $localName): ?Attr { + # To get an attribute by namespace and local name given a namespace, localName, + # and element element, run these steps: + # + # 1. If namespace is the empty string, then set it to null. + if ($namespace === '') { + $namespace = null; + } + + # 2. Return the attribute in element’s attribute list whose namespace is namespace + # and local name is localName, if any; otherwise null. + // Going to try to handle this by getting the PHP DOM to do the heavy lifting + // when we can because it's faster. + $value = parent::getAttributeNodeNS($namespace, $localName); + if (!$value) { + // Replace any offending characters with "UHHHHHH" where H are the uppercase + // hexadecimal digits of the character's code point + $namespace = $this->coerceName($namespace ?? ''); + $localName = $this->coerceName($localName); + + // The PHP DOM does not acknowledge the presence of XMLNS-namespace attributes + // sometimes, too... so this will get those as well in those circumstances. + foreach ($this->attributes as $a) { + if ($a->namespaceURI === $namespace && $a->localName === $localName) { + return $a; + } + } + return null; + } + + return ($value !== false) ? $value : null; + } } diff --git a/lib/TokenList.php b/lib/TokenList.php index f2c7aa1..1b8fa17 100644 --- a/lib/TokenList.php +++ b/lib/TokenList.php @@ -14,6 +14,9 @@ use MensBeam\Framework\MagicProperties, class TokenList implements \ArrayAccess, \Countable, \Iterator { use MagicProperties; + + public bool $rebuild = false; + protected string $localName; protected \WeakReference $element; @@ -49,6 +52,7 @@ class TokenList implements \ArrayAccess, \Countable, \Iterator { # 1. Let element be associated element. // Using a weak reference here to prevent a circular reference. $this->element = \WeakReference::create($element); + ElementMap::add($element); # 2. Let localName be associated attribute’s local name. $this->localName = $attributeLocalName; # 3. Let value be the result of getting an attribute value given element and @@ -267,7 +271,6 @@ class TokenList implements \ArrayAccess, \Countable, \Iterator { if ($value === null) { $this->tokenSet = []; - $this->tokenKeys = []; $this->_length = 0; } # 2. Otherwise, if localName is associated attribute’s local name, namespace is diff --git a/tests/cases/TestElement.php b/tests/cases/TestElement.php index 968d9d3..1930328 100644 --- a/tests/cases/TestElement.php +++ b/tests/cases/TestElement.php @@ -184,8 +184,42 @@ class TestElement extends \PHPUnit\Framework\TestCase { $this->assertSame('eek', $ook->value); // Bogus attribute $this->assertNull($d->documentElement->getAttributeNodeNS(null, 'what')); + } + - /*$d->documentElement->setAttributeNS(Parser::XMLNS_NAMESPACE, 'xmlns', Parser::HTML_NAMESPACE); - die(var_export($d->documentElement->getAttributeNodeNS(Parser::XMLNS_NAMESPACE, 'xmlns')));*/ + /** + * @covers \MensBeam\HTML\DOM\Element::getAttributeNS + */ + public function testGetAttributeNS(): void { + // Just need to test nonexistent attributes + $d = new Document(); + $d->appendChild($d->createElement('html')); + $this->assertNull($d->documentElement->getAttributeNS(Parser::HTML_NAMESPACE, 'ook')); + } + + + /** + * @covers \MensBeam\HTML\DOM\Element::hasAttributeNS + */ + public function testHasAttributeNS(): void { + // Just need to test empty string namespace + $d = new Document(); + $d->appendChild($d->createElement('html')); + $d->documentElement->setAttribute('ook', 'eek'); + $this->assertTrue($d->documentElement->hasAttributeNS('', 'ook')); + } + + + /** + * @covers \MensBeam\HTML\DOM\Element::setAttribute + * @covers \MensBeam\HTML\DOM\TokenList::__set_value + */ + public function testSetAttribute(): void { + // Just need to test classList updates + $d = new Document(); + $d->appendChild($d->createElement('html')); + $d->documentElement->classList->add('ook', 'eek'); + $d->documentElement->setAttribute('class', 'ack'); + $this->assertSame('ack', $d->documentElement->classList[0]); } } \ No newline at end of file