Browse Source

Wrangling with class lists

wrapper-classes
Dustin Wilson 3 years ago
parent
commit
b00c7f6e47
  1. 230
      lib/Element.php
  2. 5
      lib/TokenList.php
  3. 38
      tests/cases/TestElement.php

230
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;
}
}

5
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

38
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]);
}
}
Loading…
Cancel
Save