diff --git a/README.md b/README.md index a25e8b4..8e82d92 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Build Status](https://api.travis-ci.org/xinningsu/html-query.svg?branch=master)](https://travis-ci.org/xinningsu/html-query) [![Coverage Status](https://coveralls.io/repos/github/xinningsu/html-query/badge.svg?branch=master)](https://coveralls.io/github/xinningsu/html-query) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/xinningsu/html-query/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/xinningsu/html-query) +[![Code Intelligence Status](https://scrutinizer-ci.com/g/xinningsu/html-query/badges/code-intelligence.svg?b=master)](https://scrutinizer-ci.com/g/xinningsu/html-query) A jQuery-like html processor written in PHP diff --git a/src/HQ.php b/src/HQ.php index 3172d5d..053eeea 100644 --- a/src/HQ.php +++ b/src/HQ.php @@ -16,7 +16,7 @@ class HQ * * @param string $html * - * @return HtmlQuery + * @return HtmlDocument */ public static function html(string $html) { @@ -28,7 +28,7 @@ public static function html(string $html) * * @param string $file * - * @return HtmlQuery + * @return HtmlDocument */ public static function htmlFile(string $file) { @@ -40,7 +40,7 @@ public static function htmlFile(string $file) * * @param string|null $html * - * @return HtmlQuery + * @return HtmlDocument */ public static function instance(?string $html = null) { @@ -50,6 +50,6 @@ public static function instance(?string $html = null) $doc->loadHTML($html, LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED); } - return new HtmlQuery($doc, $doc); + return new HtmlDocument($doc); } } diff --git a/src/Helper.php b/src/Helper.php index f4a8478..103e83b 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -2,6 +2,7 @@ namespace Sulao\HtmlQuery; +use DOMDocument, DOMNode, DOMNodeList, DOMXPath; use Symfony\Component\CssSelector\CssSelectorConverter; use Traversable; @@ -83,6 +84,26 @@ public static function strictArrayDiff(array $arr1, array $arr2): array return array_values($arr); } + /** + * Case insensitive search + * + * @param string $needle + * @param string[] $haystack + * + * @return array + */ + public static function caseInsensitiveSearch( + string $needle, + array $haystack + ): array { + $needle = strtolower($needle); + $match = array_filter($haystack, function ($value) use ($needle) { + return $needle === strtolower($value); + }); + + return array_values($match); + } + /** * Split the class attr value to a class array * @@ -176,4 +197,53 @@ public static function isIdSelector( return false; } + + /** + * Query xpath to an array of DOMNode + * + * @param string $xpath + * @param DOMDocument $doc + * @param DOMNode|null $node + * + * @return DOMNode[] + */ + public static function xpathQuery( + string $xpath, + DOMDocument $doc, + ?DOMNode $node = null + ): array { + $docXpath = new DOMXpath($doc); + $nodeList = $docXpath->query($xpath, $node); + + if (!($nodeList instanceof DOMNodeList)) { + return []; + } + + return iterator_to_array($nodeList); + } + + /** + * Get the node with the relationship of current node. + * + * @param DOMNode $node + * @param string $relation + * + * @return DOMNode|null + */ + public static function getRelationNode(DOMNode $node, string $relation) + { + /** @var DOMNode $node */ + while (($node = $node->$relation) + && $node instanceof DOMNode + && $node->nodeType !== XML_DOCUMENT_NODE + ) { + if ($node->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + return $node; + } + + return null; + } } diff --git a/src/HtmlDocument.php b/src/HtmlDocument.php new file mode 100644 index 0000000..f4f8406 --- /dev/null +++ b/src/HtmlDocument.php @@ -0,0 +1,115 @@ +doc = $doc; + } + + /** + * Get DOMDocument + * + * @return DOMDocument + */ + public function getDoc(): DOMDocument + { + return $this->doc; + } + + /** + * Get the outer HTML content. + * + * @return string|null + */ + public function outerHtml() + { + return $this->doc->saveHTML(); + } + + /** + * Make the static object can be called as a function. + * + * @param string $selector + * + * @return HtmlQuery + */ + public function __invoke(string $selector) + { + return $this->query($selector); + } + + /** + * If the parameter is raw html, then create document fragment for it, + * If the parameter is a css selector, get the descendants + * filtered by a css selector. + * + * @param string $selector css selector or raw html + * + * @return HtmlQuery + */ + public function query(string $selector) + { + if (Helper::isRawHtml($selector)) { + $frag = $this->doc->createDocumentFragment(); + $frag->appendXML($selector); + + return $this->resolve($frag); + } + + return $this->find($selector); + } + + /** + * Get the descendants of document, filtered by a selector. + * + * @param string $selector + * + * @return HtmlQuery + */ + public function find(string $selector) + { + $nodes = Helper::xpathQuery( + Helper::toXpath($selector), + $this->doc, + $this->doc + ); + + if (Helper::isIdSelector($selector)) { + $nodes = $nodes ? $nodes[0] : []; + } + + return $this->resolve($nodes); + } + + /** + * Resolve nodes to HtmlQuery instance. + * + * @param DOMNode|DOMNode[] $nodes + * + * @return HtmlQuery + */ + protected function resolve($nodes) + { + return new HtmlQuery($this->doc, $nodes); + } +} diff --git a/src/HtmlElement.php b/src/HtmlElement.php new file mode 100644 index 0000000..d6d0982 --- /dev/null +++ b/src/HtmlElement.php @@ -0,0 +1,190 @@ +node = $node; + } + + /** + * Get the value of an attribute + * + * @param string $name + * + * @return string|null + */ + public function getAttr(string $name) + { + return $this->node->getAttribute($name); + } + + /** + * Set attribute. + * + * @param string $name + * @param string $value + */ + public function setAttr(string $name, string $value) + { + $this->node->setAttribute($name, $value); + } + + /** + * Remove an attribute. + * + * @param string $attributeName + */ + public function removeAttr(string $attributeName) + { + $this->node->removeAttribute($attributeName); + } + + /** + * Remove all attributes except the specified ones. + * + * @param string|array $except The attribute name(s) that won't be removed + */ + public function removeAllAttrs($except = []) + { + $names = []; + foreach (iterator_to_array($this->node->attributes) as $attribute) { + $names[] = $attribute->name; + } + + foreach (array_diff($names, (array) $except) as $name) { + $this->node->removeAttribute($name); + } + } + + /** + * Determine whether the node has the given attribute. + * + * @param string $attributeName + * + * @return bool + */ + public function hasAttr(string $attributeName) + { + return $this->node->hasAttribute($attributeName); + } + + /** + * Get the current value of the node. + * + * @return string|null + */ + public function getVal() + { + switch ($this->node->tagName) { + case 'input': + return $this->node->getAttribute('value'); + case 'textarea': + return $this->node->nodeValue; + case 'select': + return $this->getSelectVal(); + } + + return null; + } + + /** + * Set the value of the node. + * + * @param string $value + */ + public function setVal(string $value) + { + switch ($this->node->tagName) { + case 'input': + $this->node->setAttribute('value', $value); + break; + case 'textarea': + $this->node->nodeValue = $value; + break; + case 'select': + $this->setSelectVal($value); + break; + } + } + + /** + * Set select hag value + * + * @param string $value + */ + protected function setSelectVal(string $value) + { + if ($this->node->tagName == 'select') { + $nodes = Helper::xpathQuery( + Helper::toXpath('option:selected', 'child::'), + $this->getDoc(), + $this->node + ); + + foreach ($nodes as $node) { + $node->removeAttribute('selected'); + } + + $nodes = Helper::xpathQuery( + Helper::toXpath("option[value='{$value}']", 'child::'), + $this->getDoc(), + $this->node + ); + + if (count($nodes)) { + $nodes[0]->setAttribute('selected', 'selected'); + } + } + } + + /** + * Get select tag value + * + * @return string|null + */ + protected function getSelectVal() + { + if ($this->node->tagName === 'select') { + $xpaths = [ + Helper::toXpath('option:selected', 'child::'), + 'child::option[1]' + ]; + + foreach ($xpaths as $xpath) { + $nodes = Helper::xpathQuery( + $xpath, + $this->getDoc(), + $this->node + ); + + if (count($nodes)) { + return $nodes[0]->getAttribute('value'); + } + } + } + + return null; + } +} diff --git a/src/HtmlElementCss.php b/src/HtmlElementCss.php new file mode 100644 index 0000000..acf7932 --- /dev/null +++ b/src/HtmlElementCss.php @@ -0,0 +1,209 @@ +hasAttr('class')) { + $this->setAttr('class', $className); + return; + } + + $classNames = Helper::splitClass($className); + $class = (string) $this->getAttr('class'); + $classes = Helper::splitClass($class); + + $classArr = array_diff($classNames, $classes); + if (empty($classArr)) { + return; + } + + $class .= ' ' . implode(' ', $classArr); + $this->setAttr('class', $class); + } + + /** + * Determine whether the node is assigned the given class. + * + * @param string $className + * + * @return bool + */ + public function hasClass(string $className) + { + $class = (string) $this->getAttr('class'); + $classes = Helper::splitClass($class); + + return in_array($className, $classes); + } + + /** + * Remove a single class, multiple classes, or all classes. + * + * @param string|null $className + */ + public function removeClass(?string $className = null) + { + if (!$this->hasAttr('class')) { + return; + } + + if (is_null($className)) { + $this->removeAttr('class'); + return; + } + + $classNames = Helper::splitClass($className); + $class = (string) $this->getAttr('class'); + $classes = Helper::splitClass($class); + + $classArr = array_diff($classes, $classNames); + if (empty($classArr)) { + $this->removeAttr('class'); + return; + } + + $class = implode(' ', $classArr); + $this->setAttr('class', $class); + } + + /** + * Add or remove class(es), depending on either the class's presence + * or the value of the state argument. + * + * @param string $className + * @param bool|null $state + */ + public function toggleClass(string $className, ?bool $state = null) + { + if (!is_null($state)) { + if ($state) { + $this->addClass($className); + } else { + $this->removeClass($className); + } + return; + } + + if (!$this->hasAttr('class')) { + $this->setAttr('class', $className); + return; + } + + $classNames = Helper::splitClass($className); + $classes = Helper::splitClass((string) $this->getAttr('class')); + + $classArr = array_diff($classes, $classNames); + $classArr = array_merge( + $classArr, + array_diff($classNames, $classes) + ); + if (empty($classArr)) { + $this->removeClass($className); + return; + } + + $this->setAttr('class', implode(' ', $classArr)); + } + + /** + * Get the value of a computed style property + * + * @param string $name + * + * @return string|null + */ + public function getCss(string $name) + { + $style = (string) $this->getAttr('style'); + $css = Helper::splitCss($style); + if (!$css) { + return null; + } + + if (array_key_exists($name, $css)) { + return $css[$name]; + } + + $arr = array_change_key_case($css, CASE_LOWER); + $key = strtolower($name); + if (array_key_exists($key, $arr)) { + return $arr[$key]; + } + + return null; + } + + /** + * Set or Remove one CSS property. + * + * @param string $name + * @param string|null $value + */ + public function setCss(string $name, ?string $value) + { + if ((string) $value === '') { + $this->removeCss($name); + return; + } + + $style = (string) $this->getAttr('style'); + if (!$style) { + $this->setAttr('style', $name . ': ' . $value . ';'); + return; + } + + $css = Helper::splitCss($style); + if (!array_key_exists($name, $css)) { + $keys = Helper::caseInsensitiveSearch($name, array_keys($css)); + foreach ($keys as $key) { + unset($css[$key]); + } + } + + $css[$name] = $value; + $style = Helper::implodeCss($css); + $this->setAttr('style', $style); + } + + /** + * Remove one CSS property. + * + * @param string $name + */ + public function removeCss(string $name) + { + $style = (string) $this->getAttr('style'); + + if ($style !== '') { + $css = Helper::splitCss($style); + $keys = Helper::caseInsensitiveSearch($name, array_keys($css)); + + if (!empty($keys)) { + foreach ($keys as $key) { + unset($css[$key]); + } + + $style = Helper::implodeCss($css); + $this->setAttr('style', $style); + } + } + } +} diff --git a/src/HtmlNode.php b/src/HtmlNode.php new file mode 100644 index 0000000..07cca34 --- /dev/null +++ b/src/HtmlNode.php @@ -0,0 +1,180 @@ +node = $node; + } + + /** + * Get the outer HTML content. + * + * @return string|null + */ + public function outerHtml() + { + return $this->getDoc()->saveHTML($this->node); + } + + /** + * Get the inner HTML content. + * + * @return string|null + */ + public function getHtml() + { + $content = ''; + foreach (iterator_to_array($this->node->childNodes) as $childNode) { + $content .= $this->getDoc()->saveHTML($childNode); + } + + return $content; + } + + /** + * Get the combined text contents, including it's descendants. + * + * @return string|null + */ + public function getText() + { + return $this->node->textContent; + } + + /** + * Set the text contents. + * + * @param string $text + */ + public function setText(string $text) + { + $this->node->nodeValue = $text; + } + + /** + * Remove all child nodes from the DOM. + */ + public function empty() + { + $this->node->nodeValue = ''; + } + + /** + * Remove the node from the DOM. + */ + public function remove() + { + if ($this->node->parentNode) { + $this->node->parentNode->removeChild($this->node); + } + } + + /** + * Insert a node before the node. + * + * @param DOMNode $newNode + */ + public function before(DOMNode $newNode) + { + if ($this->node->parentNode) { + $this->node->parentNode->insertBefore($newNode, $this->node); + } + } + + /** + * Insert new node after the node. + * + * @param DOMNode $newNode + */ + public function after(DOMNode $newNode) + { + $nextSibling = $this->node->nextSibling; + + if ($nextSibling && $nextSibling->parentNode) { + $nextSibling->parentNode->insertBefore($newNode, $nextSibling); + } elseif ($this->node->parentNode) { + $this->node->parentNode->appendChild($newNode); + } + } + + /** + * Insert a node to the end of the node. + * + * @param DOMNode $newNode + */ + public function append(DOMNode $newNode) + { + $this->node->appendChild($newNode); + } + + /** + * Insert content or node(s) to the beginning of each matched node. + * + * @param DOMNode $newNode + */ + public function prepend(DOMNode $newNode) + { + if ($this->node->firstChild) { + $this->node->insertBefore($newNode, $this->node->firstChild); + } else { + $this->node->appendChild($newNode); + } + } + + /** + * Replace the node with the provided node + * + * @param DOMNode $newNode + */ + public function replaceWith(DOMNode $newNode) + { + if ($this->node->parentNode) { + $this->node->parentNode->replaceChild($newNode, $this->node); + } + } + + /** + * Remove the HTML tag of the node from the DOM. + * Leaving the child nodes in their place. + */ + public function unwrapSelf() + { + foreach (iterator_to_array($this->node->childNodes) as $childNode) { + $this->before($childNode); + } + + $this->remove(); + } + + /** + * Get DOMDocument of the node + * + * @return DOMDocument + */ + protected function getDoc(): DOMDocument + { + return $this->node instanceof DOMDocument + ? $this->node + : $this->node->ownerDocument; + } +} diff --git a/src/HtmlQuery.php b/src/HtmlQuery.php index d74db36..ebfb415 100644 --- a/src/HtmlQuery.php +++ b/src/HtmlQuery.php @@ -2,7 +2,7 @@ namespace Sulao\HtmlQuery; -use DOMDocument, DOMElement, DOMNode, DOMNodeList; +use DOMDocument, DOMNode, DOMNodeList; use Traversable; /** @@ -10,9 +10,9 @@ * * @package Sulao\HtmlQuery */ -class HtmlQuery extends Selection +class HtmlQuery extends HtmlQueryNode { - const VERSION = '1.0.0'; + const VERSION = '1.0.1'; /** * @var DOMDocument @@ -45,8 +45,8 @@ public function __construct(DOMDocument $doc, $nodes) */ public function outerHtml() { - return $this->mapFirst(function (DOMNode $node) { - return $this->doc->saveHTML($node); + return $this->mapFirst(function (HtmlNode $node) { + return $node->outerHtml(); }); } @@ -74,13 +74,8 @@ public function html(?string $html = null) */ public function getHtml() { - return $this->mapFirst(function (DOMNode $node) { - $content = ''; - foreach (iterator_to_array($node->childNodes) as $childNode) { - $content .= $this->doc->saveHTML($childNode); - } - - return $content; + return $this->mapFirst(function (HtmlNode $node) { + return $node->getHtml(); }); } @@ -127,8 +122,8 @@ public function text(?string $text = null) */ public function getText() { - return $this->mapFirst(function (DOMNode $node) { - return $node->textContent; + return $this->mapFirst(function (HtmlNode $node) { + return $node->getText(); }); } @@ -141,222 +136,11 @@ public function getText() */ public function setText(string $text) { - return $this->each(function (DOMNode $node) use ($text) { - return $node->nodeValue = $text; - }); - } - - /** - * Get the value of an attribute for the first matched node - * or set one or more attributes for every matched node. - * - * @param string|array $name - * @param string|null $value - * - * @return static|mixed|null - */ - public function attr($name, $value = null) - { - if (is_array($name)) { - foreach ($name as $key => $val) { - $this->setAttr($key, $val); - } - - return $this; - } - - if (!is_null($value)) { - return $this->setAttr($name, $value); - } - - return $this->getAttr($name); - } - - /** - * Get the value of an attribute for the first matched node - * - * @param string $name - * - * @return string|null - */ - public function getAttr(string $name) - { - return $this->mapFirst(function (DOMNode $node) use ($name) { - if (!($node instanceof DOMElement)) { - return null; - } - - return $node->getAttribute($name); - }); - } - - /** - * Set one or more attributes for every matched node. - * - * @param string $name - * @param string $value - * - * @return static - */ - public function setAttr(string $name, string $value) - { - return $this->each(function (DOMNode $node) use ($name, $value) { - if ($node instanceof DOMElement) { - $node->setAttribute($name, $value); - } + return $this->each(function (HtmlNode $node) use ($text) { + $node->setText($text); }); } - /** - * Remove an attribute from every matched nodes. - * - * @param string $attributeName - * - * @return static - */ - public function removeAttr(string $attributeName) - { - return $this->each(function (DOMNode $node) use ($attributeName) { - if ($node instanceof DOMElement) { - $node->removeAttribute($attributeName); - } - }); - } - - /** - * Remove all attributes from every matched nodes except the specified ones. - * - * @param string|array $except The attribute name(s) that won't be removed - * - * @return static - */ - public function removeAllAttrs($except = []) - { - return $this->each(function (DOMNode $node) use ($except) { - $names = []; - foreach (iterator_to_array($node->attributes) as $attribute) { - $names[] = $attribute->name; - } - - foreach (array_diff($names, (array) $except) as $name) { - if ($node instanceof DOMElement) { - $node->removeAttribute($name); - } - } - }); - } - - /** - * Determine whether any of the nodes have the given attribute. - * - * @param string $attributeName - * - * @return bool - */ - public function hasAttr(string $attributeName) - { - return $this->mapAnyTrue( - function (DOMNode $node) use ($attributeName) { - if (!($node instanceof DOMElement)) { - return false; - } - - return $node->hasAttribute($attributeName); - } - ); - } - - /** - * Alias of attr - * - * @param string|array $name - * @param string|null $value - * - * @return static|mixed|null - */ - public function prop($name, $value = null) - { - return $this->attr($name, $value); - } - - /** - * Alias of removeAttr - * - * @param string $attributeName - * - * @return static - */ - public function removeProp(string $attributeName) - { - return $this->removeAttr($attributeName); - } - - /** - * Get the value of an attribute with prefix data- for the first matched - * node, if the value is valid json string, returns the value encoded in - * json in appropriate PHP type - * - * or set one or more attributes with prefix data- for every matched node. - * - * @param string|array $name - * @param string|null $value - * - * @return static|mixed|null - */ - public function data($name, $value = null) - { - if (is_array($name)) { - $keys = array_keys($name); - $keys = array_map(function ($value) { - return 'data-' . $value; - }, $keys); - - $name = array_combine($keys, $name); - } else { - $name = 'data-' . $name; - } - - if (!is_null($value) && !is_string($value)) { - $value = (string) json_encode($value); - } - - $result = $this->attr($name, $value); - - if (is_string($result)) { - $json = json_decode($result); - if (json_last_error() === JSON_ERROR_NONE) { - return $json; - } - } - - return $result; - } - - /** - * Determine whether any of the nodes have the given attribute - * prefix with data-. - * - * @param string $name - * - * @return bool - */ - public function hasData(string $name) - { - return $this->hasAttr('data-' . $name); - } - - /** - * Remove an attribute prefix with data- from every matched nodes. - * - * @param string $name - * - * @return static - */ - public function removeData(string $name) - { - return $this->removeAttr('data-' . $name); - } - /** * Remove all child nodes of all matched nodes from the DOM. * @@ -364,8 +148,8 @@ public function removeData(string $name) */ public function empty() { - return $this->each(function (DOMNode $node) { - $node->nodeValue = ''; + return $this->each(function (HtmlNode $node) { + $node->empty(); }); } @@ -382,10 +166,8 @@ public function remove(?string $selector = null) if (!is_null($selector)) { $this->filter($selector)->remove(); } else { - $this->each(function (DOMNode $node) { - if ($node->parentNode) { - $node->parentNode->removeChild($node); - } + $this->each(function (HtmlNode $node) { + $node->remove(); }); } @@ -416,32 +198,8 @@ public function val(?string $value = null) */ public function getVal() { - return $this->mapFirst(function (DOMNode $node) { - if (!($node instanceof DOMElement)) { - return null; - } - - switch ($node->tagName) { - case 'input': - return $node->getAttribute('value'); - case 'textarea': - return $node->nodeValue; - case 'select': - $ht = $this->resolve($node); - - $selected = $ht->children('option:selected'); - if ($selected->count()) { - return $selected->getAttr('value'); - } - - $fistChild = $ht->xpathFind('child::*[1]'); - if ($fistChild->count()) { - return $fistChild->getAttr('value'); - } - break; - } - - return null; + return $this->mapFirst(function (HtmlElement $node) { + return $node->getVal(); }); } @@ -454,32 +212,8 @@ public function getVal() */ public function setVal(string $value) { - return $this->each(function (DOMNode $node) use ($value) { - if (!($node instanceof DOMElement)) { - return; - } - - switch ($node->tagName) { - case 'input': - $node->setAttribute('value', $value); - break; - case 'textarea': - $node->nodeValue = $value; - break; - case 'select': - $ht = $this->resolve($node); - - $selected = $ht->children('option:selected'); - if ($selected->count()) { - $selected->removeAttr('selected'); - } - - $options = $ht->children("option[value='{$value}']"); - if ($options->count()) { - $options->first()->setAttr('selected', 'selected'); - } - break; - } + return $this->each(function (HtmlElement $node) use ($value) { + $node->setVal($value); }); } @@ -492,23 +226,8 @@ public function setVal(string $value) */ public function addClass(string $className) { - return $this->each(function (HtmlQuery $node) use ($className) { - if (!$node->hasAttr('class')) { - $node->setAttr('class', $className); - return; - } - - $classNames = Helper::splitClass($className); - $class = (string) $node->getAttr('class'); - $classes = Helper::splitClass($class); - - $classArr = array_diff($classNames, $classes); - if (empty($classArr)) { - return; - } - - $class .= ' ' . implode(' ', $classArr); - $this->setAttr('class', $class); + return $this->each(function (HtmlElement $node) use ($className) { + $node->addClass($className); }); } @@ -522,15 +241,8 @@ public function addClass(string $className) public function hasClass(string $className) { return $this->mapAnyTrue( - function (HtmlQuery $node) use ($className) { - if (!$node->hasAttr('class')) { - return false; - } - - $class = (string) $node->getAttr('class'); - $classes = Helper::splitClass($class); - - return in_array($className, $classes); + function (HtmlElement $node) use ($className) { + return $node->hasClass($className); } ); } @@ -545,28 +257,8 @@ function (HtmlQuery $node) use ($className) { */ public function removeClass(?string $className = null) { - return $this->each(function (HtmlQuery $node) use ($className) { - if (!$node->hasAttr('class')) { - return; - } - - if (is_null($className)) { - $node->removeAttr('class'); - return; - } - - $classNames = Helper::splitClass($className); - $class = (string) $node->getAttr('class'); - $classes = Helper::splitClass($class); - - $classArr = array_diff($classes, $classNames); - if (empty($classArr)) { - $node->removeAttr('class'); - return; - } - - $class = implode(' ', $classArr); - $node->setAttr('class', $class); + return $this->each(function (HtmlElement $node) use ($className) { + $node->removeClass($className); }); } @@ -581,35 +273,8 @@ public function removeClass(?string $className = null) */ public function toggleClass(string $className, ?bool $state = null) { - return $this->each(function (HtmlQuery $node) use ($className, $state) { - if (!is_null($state)) { - if ($state) { - $node->addClass($className); - } else { - $node->removeClass($className); - } - return; - } - - if (!$this->hasAttr('class')) { - $node->setAttr('class', $className); - return; - } - - $classNames = Helper::splitClass($className); - $classes = Helper::splitClass((string) $this->getAttr('class')); - - $classArr = array_diff($classes, $classNames); - $classArr = array_merge( - $classArr, - array_diff($classNames, $classes) - ); - if (empty($classArr)) { - $node->removeClass($className); - return; - } - - $node->setAttr('class', implode(' ', $classArr)); + return $this->each(function (HtmlElement $node) use ($className, $state) { + $node->toggleClass($className, $state); }); } @@ -639,7 +304,6 @@ public function css($name, $value = null) return $this; } - /** * Get the value of a computed style property for the first matched node * @@ -649,24 +313,8 @@ public function css($name, $value = null) */ public function getCss(string $name) { - return $this->mapFirst(function (HtmlQuery $node) use ($name) { - $style = (string) $node->attr('style'); - $css = Helper::splitCss($style); - if (!$css) { - return null; - } - - if (array_key_exists($name, $css)) { - return $css[$name]; - } - - $arr = array_change_key_case($css, CASE_LOWER); - $key = strtolower($name); - if (array_key_exists($key, $arr)) { - return $arr[$key]; - } - - return null; + return $this->mapFirst(function (HtmlElement $node) use ($name) { + return $node->getCss($name); }); } @@ -680,35 +328,8 @@ public function getCss(string $name) */ public function setCss(string $name, ?string $value) { - return $this->each(function (HtmlQuery $node) use ($name, $value) { - if ((string) $value === '') { - $node->removeCss($name); - return; - } - - $style = (string) $node->attr('style'); - if (!$style) { - $node->setAttr('style', $name . ': ' . $value . ';'); - return; - } - - $css = Helper::splitCss($style); - if (!array_key_exists($name, $css)) { - $allKeys = array_keys($css); - $arr = array_combine( - $allKeys, - array_map('strtolower', $allKeys) - ) ?: []; - - $keys = array_keys($arr, strtolower($name)); - foreach ($keys as $key) { - unset($css[$key]); - } - } - - $css[$name] = $value; - $style = Helper::implodeCss($css); - $this->setAttr('style', $style); + return $this->each(function (HtmlElement $node) use ($name, $value) { + $node->setCss($name, $value); }); } @@ -721,384 +342,50 @@ public function setCss(string $name, ?string $value) */ public function removeCss(string $name) { - return $this->each(function (HtmlQuery $node) use ($name) { - $style = (string) $node->attr('style'); - if (!$style) { - return; - } - - $css = Helper::splitCss($style); - $removed = false; - if (array_key_exists($name, $css)) { - unset($css[$name]); - $removed = true; - } else { - $allKeys = array_keys($css); - $arr = array_combine( - $allKeys, - array_map('strtolower', $allKeys) - ) ?: []; - - $keys = array_keys($arr, strtolower($name)); - foreach ($keys as $key) { - unset($css[$key]); - $removed = true; - } - } - - if ($removed) { - $style = Helper::implodeCss($css); - $this->setAttr('style', $style); - } + return $this->each(function (HtmlElement $node) use ($name) { + $node->removeCss($name); }); } /** - * Insert content or node(s) before each matched node. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - public function before($content) - { - $content = $this->contentResolve($content); - - return $this->each(function (DOMNode $node, $index) use ($content) { - $content->each(function (DOMNode $newNode) use ($node, $index) { - if ($node->parentNode) { - $newNode = $index !== $this->count() - 1 - ? $newNode->cloneNode(true) - : $newNode; - - $node->parentNode->insertBefore($newNode, $node); - } - }); - }); - } - - /** - * Insert every matched node before the target. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $selector - * - * @return static - */ - public function insertBefore($selector) - { - $target = $this->targetResolve($selector); - - return $target->before($this); - } - - /** - * Insert content or node(s) after each matched node. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - public function after($content) - { - $content = $this->contentResolve($content); - - return $this->each(function (HtmlQuery $node, $index) use ($content) { - $content->each(function (DOMNode $newNode) use ($node, $index) { - $newNode = $index !== $this->count() - 1 - ? $newNode->cloneNode(true) - : $newNode; - - if ($node->next()->count()) { - $node->next()->before($newNode); - } else { - $node->parent()->append($newNode); - } - }, true); - }); - } - - /** - * Insert every matched node after the target. - * - * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector - * - * @return static - */ - public function insertAfter($selector) - { - $target = $this->targetResolve($selector); - - return $target->after($this); - } - - /** - * Insert content or node(s) to the end of every matched node. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - public function append($content) - { - $content = $this->contentResolve($content); - - return $this->each(function (DOMNode $node, $index) use ($content) { - $content->each(function (DOMNode $newNode) use ($node, $index) { - $newNode = $index !== $this->count() - 1 - ? $newNode->cloneNode(true) - : $newNode; - - $node->appendChild($newNode); - }); - }); - } - - /** - * Insert every matched node to the end of the target. - * - * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector - * - * @return static - */ - public function appendTo($selector) - { - $target = $this->targetResolve($selector); - - return $target->append($this); - } - - /** - * Insert content or node(s) to the beginning of each matched node. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - public function prepend($content) - { - $content = $this->contentResolve($content); - - return $this->each(function (DOMNode $node, $index) use ($content) { - $content->each(function (DOMNode $newNode) use ($node, $index) { - $newNode = $index !== $this->count() - 1 - ? $newNode->cloneNode(true) - : $newNode; - - if ($node->firstChild) { - $node->insertBefore($newNode, $node->firstChild); - } else { - $node->appendChild($newNode); - } - }, true); - }); - } - - /** - * Insert every matched node to the beginning of the target. - * - * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector - * - * @return static - */ - public function prependTo($selector) - { - $target = $this->targetResolve($selector); - - return $target->prepend($this); - } - - /** - * Replace each matched node with the provided new content or node(s) - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - public function replaceWith($content) - { - $content = $this->contentResolve($content); - return $this->each(function (DOMNode $node, $index) use ($content) { - if (!$node->parentNode) { - return; - } - - $len = $content->count(); - $content->each( - function (DOMNode $newNode) use ($node, $index, $len) { - $newNode = $index !== $this->count() - 1 - ? $newNode->cloneNode(true) - : $newNode; - - if ($len === 1) { - $node->parentNode->replaceChild($newNode, $node); - } else { - $this->resolve($newNode)->insertAfter($node); - } - }, - true - ); - - if ($len !== 1) { - $node->parentNode->removeChild($node); - } - }); - } - - /** - * Replace each target node with the matched node(s) - * - * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector - * - * @return static - */ - public function replaceAll($selector) - { - $target = $this->targetResolve($selector); - - return $target->replaceWith($this); - } - - /** - * Wrap an HTML structure around each matched node. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - public function wrap($content) - { - $content = $this->contentResolve($content); - $newNode = $content[0]; - - if (empty($newNode)) { - return $this; - } - - $newNode = $content[0]; - - return $this->each(function (DOMNode $node, $index) use ($newNode) { - $newNode = $index !== $this->count() - 1 - ? $newNode->cloneNode(true) - : $newNode; - - $nodes = $this->xpathQuery('descendant::*[last()]', $newNode); - if (!$nodes) { - throw new Exception('Invalid wrap html format.'); - } - - $deepestNode = end($nodes); - $node->parentNode->replaceChild($newNode, $node); - $deepestNode->appendChild($node); - }); - } - - /** - * Wrap an HTML structure around the content of each matched node. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - public function wrapInner($content) - { - $content = $this->contentResolve($content); - $newNode = $content[0]; - - if (empty($newNode)) { - return $this; - } - - return $this->each(function (DOMNode $node, $index) use ($newNode) { - $newNode = $index !== $this->count() - 1 - ? $newNode->cloneNode(true) - : $newNode; - - $nodes = $this->xpathQuery('descendant::*[last()]', $newNode); - if (!$nodes) { - throw new Exception('Invalid wrap html format.'); - } - - $deepestNode = end($nodes); - - // Can't loop $this->node->childNodes directly to append child, - // Because childNodes will change once appending child. - foreach (iterator_to_array($node->childNodes) as $childNode) { - $deepestNode->appendChild($childNode); - } - - $node->appendChild($newNode); - }); - } - - /** - * Wrap an HTML structure around all matched nodes. + * Validate the nodes * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * @param DOMNode|DOMNode[]|DOMNodeList|static $nodes * - * @return static + * @return DOMNode[] */ - public function wrapAll($content) + protected function validateNodes($nodes) { - $content = $this->contentResolve($content); - if (!$content->count()) { - return $this; - } + $nodes = $this->convertNodes($nodes); - $newNode = $content[0]; - $this->each(function (DOMNode $node, $index) use ($newNode) { - if ($index === 0) { - $this->resolve($node)->wrap($newNode); - } else { - $this->nodes[0]->parentNode->appendChild($node); + array_map(function ($node) { + if (!($node instanceof DOMNode)) { + throw new Exception( + 'Expect an instance of DOMNode, ' + . gettype($node) . ' given.' + ); } - }); - - return $this; - } - - /** - * Remove the parents of the matched nodes from the DOM. - * A optional selector to check the parent node against. - * - * @param string|null $selector - * - * @return static - */ - public function unwrap(?string $selector = null) - { - return $this->parent($selector)->unwrapSelf(); - } - /** - * Remove the HTML tag of the matched nodes from the DOM. - * Leaving the child nodes in their place. - * - * @return static - */ - public function unwrapSelf() - { - return $this->each(function (DOMNode $node) { - if (!$node->parentNode) { - return; - } + $document = $node->ownerDocument ?: $node; - foreach (iterator_to_array($node->childNodes) as $childNode) { - $node->parentNode->insertBefore($childNode, $node); + if ($document !== $this->doc) { + throw new Exception( + 'The DOMNode does not belong to the DOMDocument.' + ); } + }, $nodes); - $node->parentNode->removeChild($node); - }); + return $nodes; } /** - * Validate the nodes + * Convert nodes to array * * @param DOMNode|DOMNode[]|DOMNodeList|static $nodes * - * @return DOMNode[] - * @throws Exception + * @return array */ - protected function validateNodes($nodes) + protected function convertNodes($nodes): array { if (empty($nodes)) { $nodes = []; @@ -1108,24 +395,6 @@ protected function validateNodes($nodes) $nodes = [$nodes]; } - $nodes = Helper::strictArrayUnique($nodes); - foreach ($nodes as $node) { - if (!($node instanceof DOMNode)) { - throw new Exception( - 'Expect an instance of DOMNode, ' - . gettype($node) . ' given.' - ); - } - - if ((!$node->ownerDocument && $node !== $this->doc) - || ($node->ownerDocument && $node->ownerDocument !== $this->doc) - ) { - throw new Exception( - 'The DOMNode does not belong to the DOMDocument.' - ); - } - } - - return $nodes; + return Helper::strictArrayUnique($nodes); } } diff --git a/src/HtmlQueryAttribute.php b/src/HtmlQueryAttribute.php new file mode 100644 index 0000000..64566d1 --- /dev/null +++ b/src/HtmlQueryAttribute.php @@ -0,0 +1,202 @@ + $val) { + $this->setAttr($key, $val); + } + + return $this; + } + + if (!is_null($value)) { + return $this->setAttr($name, $value); + } + + return $this->getAttr($name); + } + + /** + * Get the value of an attribute for the first matched node + * + * @param string $name + * + * @return string|null + */ + public function getAttr(string $name) + { + return $this->mapFirst(function (HtmlElement $node) use ($name) { + return $node->getAttr($name); + }); + } + + /** + * Set one or more attributes for every matched node. + * + * @param string $name + * @param string $value + * + * @return static + */ + public function setAttr(string $name, string $value) + { + return $this->each(function (HtmlElement $node) use ($name, $value) { + $node->setAttr($name, $value); + }); + } + + /** + * Remove an attribute from every matched nodes. + * + * @param string $attributeName + * + * @return static + */ + public function removeAttr(string $attributeName) + { + return $this->each(function (HtmlElement $node) use ($attributeName) { + $node->removeAttr($attributeName); + }); + } + + /** + * Remove all attributes from every matched nodes except the specified ones. + * + * @param string|array $except The attribute name(s) that won't be removed + * + * @return static + */ + public function removeAllAttrs($except = []) + { + return $this->each(function (HtmlElement $node) use ($except) { + $node->removeAllAttrs($except); + }); + } + + /** + * Determine whether any of the nodes have the given attribute. + * + * @param string $attributeName + * + * @return bool + */ + public function hasAttr(string $attributeName) + { + return $this->mapAnyTrue( + function (HtmlElement $node) use ($attributeName) { + return $node->hasAttr($attributeName); + } + ); + } + + /** + * Alias of attr + * + * @param string|array $name + * @param string|null $value + * + * @return static|mixed|null + */ + public function prop($name, $value = null) + { + return $this->attr($name, $value); + } + + /** + * Alias of removeAttr + * + * @param string $attributeName + * + * @return static + */ + public function removeProp(string $attributeName) + { + return $this->removeAttr($attributeName); + } + + /** + * Get the value of an attribute with prefix data- for the first matched + * node, if the value is valid json string, returns the value encoded in + * json in appropriate PHP type + * + * or set one or more attributes with prefix data- for every matched node. + * + * @param string|array $name + * @param string|array|null $value + * + * @return static|mixed|null + */ + public function data($name, $value = null) + { + if (is_array($name)) { + array_walk($name, function ($val, $key) { + $this->data($key, $val); + }); + + return $this; + } + + $name = 'data-' . $name; + + if (is_null($value)) { + $result = $this->getAttr($name); + + $json = json_decode($result); + if (json_last_error() === JSON_ERROR_NONE) { + return $json; + } + + return $result; + } + + if (is_array($value)) { + $value = (string) json_encode($value); + } + + return $this->setAttr($name, $value); + } + + /** + * Determine whether any of the nodes have the given attribute + * prefix with data-. + * + * @param string $name + * + * @return bool + */ + public function hasData(string $name) + { + return $this->hasAttr('data-' . $name); + } + + /** + * Remove an attribute prefix with data- from every matched nodes. + * + * @param string $name + * + * @return static + */ + public function removeData(string $name) + { + return $this->removeAttr('data-' . $name); + } +} diff --git a/src/HtmlQueryNode.php b/src/HtmlQueryNode.php new file mode 100644 index 0000000..95de385 --- /dev/null +++ b/src/HtmlQueryNode.php @@ -0,0 +1,325 @@ +contentResolve($content); + + return $this->each(function (HtmlNode $node, $index) use ($content) { + $content->each(function (DOMNode $newNode) use ($node, $index) { + $newNode = $this->newNode($newNode, $index); + $node->before($newNode); + }); + }); + } + + /** + * Insert every matched node before the target. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $selector + * + * @return static + */ + public function insertBefore($selector) + { + $target = $this->targetResolve($selector); + + return $target->before($this); + } + + /** + * Insert content or node(s) after each matched node. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + public function after($content) + { + $content = $this->contentResolve($content); + + return $this->each(function (HtmlNode $node, $index) use ($content) { + $content->each(function (DOMNode $newNode) use ($node, $index) { + $newNode = $this->newNode($newNode, $index); + $node->after($newNode); + }, true); + }); + } + + /** + * Insert every matched node after the target. + * + * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector + * + * @return static + */ + public function insertAfter($selector) + { + $target = $this->targetResolve($selector); + + return $target->after($this); + } + + /** + * Insert content or node(s) to the end of every matched node. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + public function append($content) + { + $content = $this->contentResolve($content); + + return $this->each(function (HtmlNode $node, $index) use ($content) { + $content->each(function (DOMNode $newNode) use ($node, $index) { + $newNode = $this->newNode($newNode, $index); + $node->append($newNode); + }); + }); + } + + /** + * Insert every matched node to the end of the target. + * + * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector + * + * @return static + */ + public function appendTo($selector) + { + $target = $this->targetResolve($selector); + + return $target->append($this); + } + + /** + * Insert content or node(s) to the beginning of each matched node. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + public function prepend($content) + { + $content = $this->contentResolve($content); + + return $this->each(function (HtmlNode $node, $index) use ($content) { + $content->each(function (DOMNode $newNode) use ($node, $index) { + $newNode = $this->newNode($newNode, $index); + $node->prepend($newNode); + }, true); + }); + } + + /** + * Insert every matched node to the beginning of the target. + * + * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector + * + * @return static + */ + public function prependTo($selector) + { + $target = $this->targetResolve($selector); + + return $target->prepend($this); + } + + /** + * Replace each matched node with the provided new content or node(s) + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + public function replaceWith($content) + { + $content = $this->contentResolve($content); + return $this->each(function (DOMNode $node, $index) use ($content) { + if (!$node->parentNode) { + return; + } + + $len = $content->count(); + $content->each( + function (DOMNode $newNode) use ($node, $index, $len) { + $newNode = $this->newNode($newNode, $index); + + if ($len === 1) { + $node->parentNode->replaceChild($newNode, $node); + } else { + $this->resolve($newNode)->insertAfter($node); + } + }, + true + ); + + if ($len !== 1) { + $node->parentNode->removeChild($node); + } + }); + } + + /** + * Replace each target node with the matched node(s) + * + * @param string|DOMNode|DOMNode[]DOMNodeList|static $selector + * + * @return static + */ + public function replaceAll($selector) + { + $target = $this->targetResolve($selector); + + return $target->replaceWith($this); + } + + /** + * Wrap an HTML structure around each matched node. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + public function wrap($content) + { + $content = $this->contentResolve($content); + $newNode = $content[0]; + + if (empty($newNode)) { + return $this; + } + + return $this->each(function (DOMNode $node, $index) use ($newNode) { + $newNode = $this->newNode($newNode, $index); + + $nodes = $this->xpathQuery('descendant::*[last()]', $newNode); + if (!$nodes) { + throw new Exception('Invalid wrap html format.'); + } + + $deepestNode = end($nodes); + $node->parentNode->replaceChild($newNode, $node); + $deepestNode->appendChild($node); + }); + } + + /** + * Wrap an HTML structure around the content of each matched node. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + public function wrapInner($content) + { + $content = $this->contentResolve($content); + $newNode = $content[0]; + + if (empty($newNode)) { + return $this; + } + + return $this->each(function (DOMNode $node, $index) use ($newNode) { + $newNode = $this->newNode($newNode, $index); + + $nodes = $this->xpathQuery('descendant::*[last()]', $newNode); + if (!$nodes) { + throw new Exception('Invalid wrap html format.'); + } + + $deepestNode = end($nodes); + + foreach (iterator_to_array($node->childNodes) as $childNode) { + $deepestNode->appendChild($childNode); + } + + $node->appendChild($newNode); + }); + } + + /** + * Wrap an HTML structure around all matched nodes. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + public function wrapAll($content) + { + $content = $this->contentResolve($content); + if (!$content->count()) { + return $this; + } + + $newNode = $content[0]; + $this->each(function (DOMNode $node, $index) use ($newNode) { + if ($index === 0) { + $this->resolve($node)->wrap($newNode); + } else { + $this->nodes[0]->parentNode->appendChild($node); + } + }); + + return $this; + } + + /** + * Remove the parents of the matched nodes from the DOM. + * A optional selector to check the parent node against. + * + * @param string|null $selector + * + * @return static + */ + public function unwrap(?string $selector = null) + { + return $this->parent($selector)->unwrapSelf(); + } + + /** + * Remove the HTML tag of the matched nodes from the DOM. + * Leaving the child nodes in their place. + * + * @return static + */ + public function unwrapSelf() + { + return $this->each(function (HtmlNode $node) { + $node->unwrapSelf(); + }); + } + + /** + * When the selection needs a new node, return the original one or a clone. + * + * @param DOMNode $newNode + * @param int $index + * + * @return DOMNode + */ + protected function newNode(DOMNode $newNode, int $index) + { + return $index !== $this->count() - 1 + ? $newNode->cloneNode(true) + : $newNode; + } +} diff --git a/src/Resolver.php b/src/Resolver.php new file mode 100644 index 0000000..f866493 --- /dev/null +++ b/src/Resolver.php @@ -0,0 +1,174 @@ +doc, $nodes); + } + + /** + * If the parameter is a css selector, get the descendants + * of dom document filtered by the css selector. + * If the parameter is selection, resolve that selection to static object. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $selector + * + * @return static + */ + protected function targetResolve($selector) + { + if (is_string($selector)) { + return $this->resolve($this->doc)->find($selector); + } + + return $this->resolve($selector); + } + + /** + * If the parameter is string, consider it as raw html, + * then create document fragment for it. + * If the parameter is selection, resolve that selection to static instance. + * + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content + * + * @return static + */ + protected function contentResolve($content) + { + if (is_string($content)) { + return $this->htmlResolve($content); + } + + return $this->resolve($content); + } + + /** + * Resolve the html content to static instance. + * + * @param string $html + * + * @return static + */ + protected function htmlResolve(string $html) + { + $frag = $this->doc->createDocumentFragment(); + $frag->appendXML($html); + + return $this->resolve($frag); + } + + /** + * Resolve the nodes under the relation to static instance. + * up to but not including the node matched by the $until selector. + * + * @param string $relation + * @param string|DOMNode|DOMNode[]|DOMNodeList|static $until + * + * @return static + */ + protected function relationResolve(string $relation, ?string $until = null) + { + $untilNodes = !is_null($until) + ? $this->targetResolve($until)->nodes + : []; + + $nodes = []; + foreach ($this->nodes as $node) { + while ($node = Helper::getRelationNode($node, $relation)) { + if (in_array($node, $untilNodes, true)) { + break; + } + + if (!in_array($node, $nodes, true)) { + $nodes[] = $node; + } + } + } + + return $this->resolve($nodes); + } + + /** + * Resolve the xpath to static instance. + * + * @param string $xpath + * + * @return static + */ + protected function xpathResolve(string $xpath) + { + $nodes = []; + foreach ($this->nodes as $node) { + $nodes = array_merge($nodes, $this->xpathQuery($xpath, $node)); + } + + $nodes = Helper::strictArrayUnique($nodes); + + return $this->resolve($nodes); + } + + /** + * Query xpath to an array of DOMNode + * + * @param string $xpath + * @param DOMNode|null $node + * + * @return DOMNode[] + */ + protected function xpathQuery( + string $xpath, + ?DOMNode $node = null + ): array { + return Helper::xpathQuery($xpath, $this->doc, $node); + } +} diff --git a/src/Selection.php b/src/Selection.php index f2529ae..366e02c 100644 --- a/src/Selection.php +++ b/src/Selection.php @@ -4,8 +4,9 @@ use ArrayAccess, ArrayIterator; use Closure, Countable; -use DOMDocument, DOMNode; +use DOMDocument, DOMNode, DOMElement; use IteratorAggregate; +use ReflectionFunction; /** * Class Selection @@ -46,12 +47,15 @@ public function getNodes(): array */ public function each(Closure $function, bool $reverse = false) { - $resolve = $this->shouldResolve($function, 0); + $class = $this->getClosureClass($function, 0); $nodes = $reverse ? array_reverse($this->nodes, true) : $this->nodes; foreach ($nodes as $index => $node) { - $node = $resolve ? $this->resolve($node) : $node; - $function($node, $index); + $node = $this->closureResolve($class, $node); + + if (!empty($node)) { + $function($node, $index); + } } return $this; @@ -67,12 +71,12 @@ public function each(Closure $function, bool $reverse = false) */ public function map(Closure $function) { - $resolve = $this->shouldResolve($function, 0); + $class = $this->getClosureClass($function, 0); $data = []; foreach ($this->nodes as $index => $node) { - $node = $resolve ? $this->resolve($node) : $node; - $data[] = $function($node, $index); + $node = $this->closureResolve($class, $node); + $data[] = !empty($node) ? $function($node, $index) : null; } return $data; @@ -88,11 +92,11 @@ public function map(Closure $function) */ public function mapAnyTrue(Closure $function) { - $resolve = $this->shouldResolve($function, 0); + $class = $this->getClosureClass($function, 0); foreach ($this->nodes as $index => $node) { - $node = $resolve ? $this->resolve($node) : $node; - if ($function($node, $index)) { + $node = $this->closureResolve($class, $node); + if (!empty($node) && $function($node, $index)) { return true; } } @@ -114,10 +118,10 @@ public function mapFirst(Closure $function) return null; } - $resolve = $this->shouldResolve($function, 0); - $node = $resolve ? $this->resolve($this->nodes[0]) : $this->nodes[0]; + $class = $this->getClosureClass($function, 0); + $node = $this->closureResolve($class, $this->nodes[0]); - return $function($node); + return !empty($node) ? $function($node) : null; } /** @@ -200,8 +204,7 @@ public function offsetSet($offset, $value) { if (!($value instanceof DOMNode)) { throw new Exception( - 'Expect an instance of DOMNode, ' - . gettype($value) . ' given.' + 'Expect an instance of DOMNode, ' . gettype($value) . ' given.' ); } @@ -224,4 +227,54 @@ public function offsetGet($offset) { return isset($this->nodes[$offset]) ? $this->nodes[$offset] : null; } + + + + /** + * Get the class of the specified parameter of the closure. + * + * @param Closure $function + * @param int $index Which parameter of the closure, starts with 0 + * + * @return string + */ + protected function getClosureClass(Closure $function, int $index) + { + $reflection = new ReflectionFunction($function); + $parameters = $reflection->getParameters(); + + if (!empty($parameters) && array_key_exists($index, $parameters)) { + $class = $parameters[$index]->getClass(); + if (!empty($class)) { + return $class->getName(); + } + } + + return ''; + } + + /** + * Resolve the node to static or HtmlElement instance or leaving it as DOMNode, + * Then pass it to closure + * + * @param string $class + * @param DOMNode $node + * + * @return DOMNode|HtmlElement|HtmlNode|static|null + */ + protected function closureResolve(string $class, DOMNode $node) + { + if ($class === static::class) { + return $this->resolve($node); + } elseif ($class === HtmlElement::class) { + if (!($node instanceof DOMElement)) { + return null; + } + return new HtmlElement($node); + } elseif ($class === HtmlNode::class) { + return new HtmlNode($node); + } + + return $node; + } } diff --git a/src/Selector.php b/src/Selector.php index 62568a4..8c46616 100644 --- a/src/Selector.php +++ b/src/Selector.php @@ -3,8 +3,7 @@ namespace Sulao\HtmlQuery; use Closure; -use DOMDocument, DOMNode, DOMNodeList, DOMXPath; -use ReflectionFunction; +use DOMDocument, DOMNode, DOMNodeList; /** * Trait Selector @@ -13,6 +12,8 @@ */ trait Selector { + use Resolver; + /** * @var DOMDocument */ @@ -23,15 +24,8 @@ trait Selector */ protected $nodes; - /** - * Selector constructor. - * - * @param DOMDocument $doc - * @param DOMNode|DOMNode[]|DOMNodeList|static $nodes - * - * @return static - */ - abstract public function __construct(DOMDocument $doc, $nodes); + abstract protected function getClosureClass(Closure $function, int $index); + abstract protected function closureResolve(string $class, DOMNode $node); /** * Make the static object can be called as a function. @@ -78,14 +72,14 @@ public function query(string $selector) public function find($selector) { if (is_string($selector)) { - $selection = $this->xpathFind(Helper::toXpath($selector)); + $selection = $this->xpathResolve(Helper::toXpath($selector)); return Helper::isIdSelector($selector) ? $this->resolve($selection->nodes[0] ?? []) : $selection; } - $descendants = $this->xpathFind('descendant::*'); + $descendants = $this->xpathResolve('descendant::*'); return $descendants->intersect($selector); } @@ -101,13 +95,14 @@ public function filter($selector) { if (is_string($selector)) { $xpath = Helper::toXpath($selector, 'self::'); - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } elseif ($selector instanceof Closure) { - $resolve = $this->shouldResolve($selector, 1); + $class = $this->getClosureClass($selector, 1); $nodes = []; foreach ($this->nodes as $key => $node) { - if ($selector($key, $resolve ? $this->resolve($node) : $node)) { + $resolve = $this->closureResolve($class, $node); + if (!empty($resolve) && $selector($key, $resolve)) { $nodes[] = $node; } } @@ -131,7 +126,7 @@ public function parent(?string $selector = null) $selector = is_null($selector) ? '*' : $selector; $xpath = Helper::toXpath($selector, 'parent::'); - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -147,7 +142,7 @@ public function parents(?string $selector = null) $selector = is_null($selector) ? '*' : $selector; $xpath = Helper::toXpath($selector, 'ancestor::'); - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -176,7 +171,7 @@ public function children(?string $selector = null) $selector = is_null($selector) ? '*' : $selector; $xpath = Helper::toXpath($selector, 'child::'); - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -192,7 +187,7 @@ public function siblings(?string $selector = null) $xpath = is_null($selector) ? '*' : Helper::toXpath($selector, ''); $xpath = "preceding-sibling::{$xpath}|following-sibling::{$xpath}"; - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -208,7 +203,7 @@ public function prev(?string $selector = null) $xpath = is_null($selector) ? '*' : Helper::toXpath($selector, ''); $xpath = "preceding-sibling::{$xpath}[1]"; - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -224,7 +219,7 @@ public function prevAll(?string $selector = null) $xpath = is_null($selector) ? '*' : Helper::toXpath($selector, ''); $xpath = "preceding-sibling::{$xpath}"; - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -253,7 +248,7 @@ public function next(?string $selector = null) $xpath = is_null($selector) ? '*' : Helper::toXpath($selector, ''); $xpath = "following-sibling::{$xpath}[1]"; - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -269,7 +264,7 @@ public function nextAll(?string $selector = null) $xpath = is_null($selector) ? '*' : Helper::toXpath($selector, ''); $xpath = "following-sibling::{$xpath}"; - return $this->xpathFind($xpath); + return $this->xpathResolve($xpath); } /** @@ -350,173 +345,4 @@ public function is($selector): bool return false; } - - /** - * Resolve DOMNode(s) to a static instance. - * - * @param DOMNode|DOMNode[]|DOMNodeList|static $nodes - * - * @return static - */ - protected function resolve($nodes) - { - if ($nodes instanceof static) { - return $nodes; - } - - return new static($this->doc, $nodes); - } - - /** - * If the parameter is a css selector, get the descendants - * of dom document filtered by the css selector. - * If the parameter is selection, resolve that selection to static object. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $selector - * - * @return static - */ - protected function targetResolve($selector) - { - if (is_string($selector)) { - return $this->resolve($this->doc)->find($selector); - } - - return $this->resolve($selector); - } - - /** - * If the parameter is string, consider it as raw html, - * then create document fragment for it. - * If the parameter is selection, resolve that selection to static instance. - * - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $content - * - * @return static - */ - protected function contentResolve($content) - { - if (is_string($content)) { - return $this->htmlResolve($content); - } - - return $this->resolve($content); - } - - /** - * Resolve the html content to static instance. - * - * @param string $html - * - * @return static - */ - protected function htmlResolve(string $html) - { - $frag = $this->doc->createDocumentFragment(); - $frag->appendXML($html); - - return $this->resolve($frag); - } - - /** - * Resolve the nodes under the relation to static instance. - * up to but not including the node matched by the $until selector. - * - * @param string $relation - * @param string|DOMNode|DOMNode[]|DOMNodeList|static $until - * - * @return static - */ - protected function relationResolve(string $relation, ?string $until = null) - { - $until = !is_null($until) - ? $this->targetResolve($until)->nodes - : null; - - $nodes = []; - foreach ($this->nodes as $node) { - while (($node = $node->$relation) - && $node->nodeType !== XML_DOCUMENT_NODE - ) { - if ($node->nodeType !== XML_ELEMENT_NODE) { - continue; - } - - if (!is_null($until) && $this->resolve($node)->is($until)) { - break; - } - - if (!in_array($node, $nodes, true)) { - $nodes[] = $node; - } - } - } - - return $this->resolve($nodes); - } - - /** - * Determine where the parameter of the closure should resolve to static, - * or just leave it as DOMNode - * - * @param Closure $function - * @param int $index Which parameter of the closure, starts with 0 - * - * @return bool - */ - protected function shouldResolve(Closure $function, $index = 0) - { - $reflection = new ReflectionFunction($function); - - $parameters = $reflection->getParameters(); - if (!empty($parameters) && array_key_exists($index, $parameters)) { - $class = $parameters[$index]->getClass(); - if ($class && $class->isInstance($this)) { - return true; - } - } - - return false; - } - - /** - * Resolve the xpath to static instance. - * - * @param string $xpath - * - * @return static - */ - protected function xpathFind(string $xpath) - { - $nodes = []; - foreach ($this->nodes as $node) { - $nodes = array_merge($nodes, $this->xpathQuery($xpath, $node)); - } - - $nodes = Helper::strictArrayUnique($nodes); - - return $this->resolve($nodes); - } - - /** - * Query xpath to an array of DOMNode - * - * @param string $xpath - * @param DOMNode|null $node - * - * @return DOMNode[] - */ - protected function xpathQuery( - string $xpath, - ?DOMNode $node = null - ): array { - $docXpath = new DOMXpath($this->doc); - $nodeList = $docXpath->query($xpath, $node); - - if (!($nodeList instanceof DOMNodeList)) { - return []; - } - - return iterator_to_array($nodeList); - } } diff --git a/tests/AttributeTest.php b/tests/AttributeTest.php index 1ae5ede..a62a751 100644 --- a/tests/AttributeTest.php +++ b/tests/AttributeTest.php @@ -65,8 +65,6 @@ public function testAttr() 100, $hq->find('img')->eq(1)->setAttr('width', 100)->attr('width') ); - - $this->assertNull($hq->attr('none')); } public function testRemoveAttr() @@ -154,8 +152,6 @@ public function testHasAttr() $this->assertTrue($hq->find('img')->eq(0)->hasAttr('alt')); $this->assertFalse($hq->find('img')->eq(1)->hasAttr('alt')); - - $this->assertFalse($hq->hasAttr('alt')); } public function testProp() @@ -251,6 +247,9 @@ public function testData() $data = $hq->find('.p-0')->data('content'); $this->assertEquals(1, $data->id); $this->assertEquals('dom', $data->tag); + + $hq->find('.p-0')->data('json', '{id:1, test:'); + $this->assertEquals('{id:1, test:', $hq->find('.p-0')->data('json')); } public function testHasData() @@ -513,5 +512,11 @@ public function testCss() '', $hq->find('img')->eq(0)->outerHtml() ); + + $hq->find('img')->eq(0)->removeCss('width'); + $this->assertEquals( + '', + $hq->find('img')->eq(0)->outerHtml() + ); } } diff --git a/tests/ContentTest.php b/tests/ContentTest.php index 76428bd..ad8efc6 100644 --- a/tests/ContentTest.php +++ b/tests/ContentTest.php @@ -1,7 +1,7 @@ find("select[name='type']")->val() ); - $hq->val('test'); - $this->assertNull($hq->val()); $this->assertNull($hq->find('form')->val()); $html = ' @@ -162,6 +160,9 @@ public function testOuterHtml() $hq->find('p')->eq(1)->outerHtml(), '

test2

' ); + + $query = new HtmlQuery($hq->getDoc(), $hq->getDoc()); + $this->assertHtmlEquals($html, $query->outerHtml()); } public function testText() diff --git a/tests/HQTest.php b/tests/HQTest.php index 3f4940d..5d470e8 100644 --- a/tests/HQTest.php +++ b/tests/HQTest.php @@ -1,7 +1,7 @@ assertInstanceOf(HtmlQuery::class, $hq); - $this->assertCount(1, $hq); + $this->assertInstanceOf(HtmlDocument::class, $hq); $this->assertHtmlEquals($html, $hq->outerHtml()); } @@ -30,16 +29,14 @@ public function testHtmlFile() $file = __DIR__ . '/test.html'; $hq = HQ::htmlFile($file); - $this->assertInstanceOf(HtmlQuery::class, $hq); - $this->assertCount(1, $hq); + $this->assertInstanceOf(HtmlDocument::class, $hq); $this->assertHtmlEquals(file_get_contents($file), $hq->outerHtml()); } public function testHtmlInstance() { $hq = HQ::instance(); - $this->assertInstanceOf(HtmlQuery::class, $hq); - $this->assertCount(1, $hq); + $this->assertInstanceOf(HtmlDocument::class, $hq); $this->assertEmpty(trim($hq->outerHtml())); $html = ' @@ -55,8 +52,7 @@ public function testHtmlInstance() '; $hq = HQ::instance($html); - $this->assertInstanceOf(HtmlQuery::class, $hq); - $this->assertCount(1, $hq); + $this->assertInstanceOf(HtmlDocument::class, $hq); $this->assertHtmlEquals($html, $hq->outerHtml()); } } diff --git a/tests/HelperTest.php b/tests/HelperTest.php index 16e67cb..2e66868 100644 --- a/tests/HelperTest.php +++ b/tests/HelperTest.php @@ -108,4 +108,31 @@ public function testIsIdSelector() $this->assertFalse(Helper::isIdSelector('.class')); $this->assertFalse(Helper::isIdSelector('ul > li p')); } + + public function testCaseInsensitiveSearch() + { + $arr = ['needle', 'needle', 'haystack']; + $this->assertEquals( + ['needle', 'needle'], + Helper::caseInsensitiveSearch('needle', $arr) + ); + $this->assertEquals( + ['needle', 'needle'], + Helper::caseInsensitiveSearch('neeDle', $arr) + ); + $this->assertEquals( + [], + Helper::caseInsensitiveSearch('needles', $arr) + ); + + $arr = ['needle', 'NEEDLE', 'haystack']; + $this->assertEquals( + ['needle', 'NEEDLE'], + Helper::caseInsensitiveSearch('needle', $arr) + ); + $this->assertEquals( + ['needle', 'NEEDLE'], + Helper::caseInsensitiveSearch('neeDle', $arr) + ); + } } diff --git a/tests/HtmlNodeTest.php b/tests/HtmlNodeTest.php new file mode 100644 index 0000000..984d2f2 --- /dev/null +++ b/tests/HtmlNodeTest.php @@ -0,0 +1,61 @@ + +
  • PHP
  • +
  • Dom
  • +
  • JS
  • + + '; + $hq = HQ::html($html); + $php = $hq('.php')[0]; + $dom = $hq('.dom')[0]; + $js = $hq('.js')[0]; + + $hn = new HtmlElement($php); + $hn->replaceWith($dom); + + $this->assertHtmlEquals( + ' + ', + $hq->find('.ul')->outerHtml() + ); + } + + public function testClosureResolve() + { + $html = ' + + '; + + $hq = HQ::html($html); + $ht = $hq('

    html query

    '); + $ht->setAttr('name', 'test'); + $this->assertHtmlEquals( + '

    html query

    ', + $ht->outerHtml() + ); + + $ht->setAttr('name', 'test'); + $this->assertEquals( + [null], + $ht->map(function (HtmlElement $node) { + return $node->getAttr('test'); + }) + ); + } +} diff --git a/tests/NodeTest.php b/tests/NodeTest.php index bf1d0c3..6e53114 100644 --- a/tests/NodeTest.php +++ b/tests/NodeTest.php @@ -1,7 +1,7 @@ PHP '; - $hq = HQ::html($html); - $hq->unwrapSelf(); - // won't unwrap if no parent node - $this->assertHtmlEquals( - '', - $hq->outerHtml() - ); } public function testBefore() @@ -604,6 +595,14 @@ public function testAfter() ', $hq->outerHtml() ); + + $html = ''; + $hq = HQ::html($html); + $hq->find('.php')->after('
  • front end
  • '); + $this->assertHtmlEquals( + '', + $hq->find('ul')->outerHtml() + ); } public function testInsertAfter() @@ -1059,6 +1058,12 @@ public function testReplaceWith() '; $hq = HQ::html($html); + + $query = new HtmlQuery($hq->getDoc(), $hq->getDoc()); + $query->replaceWith('
  • js
  • '); + $this->assertHtmlEquals($html, $query->outerHtml()); + + $hq->find('.ul li')->replaceWith('
  • js
  • '); $this->assertHtmlEquals( ' @@ -1123,15 +1128,6 @@ public function testReplaceWith()
  • PHP
  • '; - $hq = HQ::html($html); - $hq->replaceWith(''); - // won't replace if no parent node - $this->assertHtmlEquals( - '', - $hq->outerHtml() - ); } public function testReplaceAll() diff --git a/tests/ResolverTest.php b/tests/ResolverTest.php index b206f9b..aaebfbb 100644 --- a/tests/ResolverTest.php +++ b/tests/ResolverTest.php @@ -1,22 +1,24 @@

    foo

    -
    bar
    -
    apple
    -
    orange
    -
    banana
    + +

    foo

    +
    bar
    +
    apple
    +
    orange
    +
    banana
    + '; $hq = HQ::html($html); - $nodes = $this->protectMethod($hq, 'xpathQuery')( + $nodes = $this->protectMethod($hq('body'), 'xpathQuery')( Helper::toXpath('.fruit') ); $instance = $this->protectMethod($hq, 'resolve')($nodes); @@ -27,28 +29,30 @@ public function testResolve() $this->assertEquals('apple', $instance->html()); } - public function testXpathFind() + public function testXpathResolve() { $html = ' -

    foo

    -
    bar
    -
    apple
    -
    orange
    -
    banana
    + +

    foo

    +
    bar
    +
    apple
    +
    orange
    +
    banana
    + '; $doc = new DOMDocument(); $doc->loadHTML($html); - $hq = HQ::html($html); + $hq = HQ::html($html)('body'); - $xpathFind = $this->protectMethod($hq, 'xpathFind'); + $xpathResolve = $this->protectMethod($hq, 'xpathResolve'); $xpath = "descendant::*[@class and contains(concat(' ', " . "normalize-space(@class), ' '), ' fruit ')]"; $this->assertEquals( $this->protectMethod($hq, 'xpathQuery')($xpath), - $xpathFind($xpath)->toArray() + $xpathResolve($xpath)->toArray() ); } @@ -103,13 +107,15 @@ public function testXpathQuery() public function testTargetResolve() { $html = ' -

    foo

    -
    bar
    -
    apple
    -
    orange
    -
    banana
    + +

    foo

    +
    bar
    +
    apple
    +
    orange
    +
    banana
    + '; - $hq = HQ::html($html); + $hq = HQ::html($html)('body'); $nodes = $this->protectMethod($hq, 'xpathQuery')( Helper::toXpath('.bar') @@ -136,7 +142,7 @@ public function testContentResolve()
    banana
    '; - $hq = HQ::html($html); + $hq = HQ::html($html)('.container'); ($this->protectMethod($hq, 'contentResolve')( '
    pear
    ' @@ -220,20 +226,64 @@ public function testHtmlResolve() ); } - public function testShouldResolve() + public function testGetClosureClass() { $doc = new DOMDocument(); $hq = new HtmlQuery($doc, []); - $shouldResolve = $this->protectMethod($hq, 'shouldResolve'); + $getClosureClass = $this->protectMethod($hq, 'getClosureClass'); + + $this->assertEquals( + $getClosureClass(function (HtmlQuery $hq) { + }, 0), + HtmlQuery::class + ); + + $this->assertEquals( + $getClosureClass(function ($index, HtmlElement $hq) { + }, 1), + HtmlElement::class + ); + + $this->assertEquals( + $getClosureClass(function ($hq) { + }, 0), + '' + ); + $this->assertEquals( + $getClosureClass(function ($index, DOMNode $hq) { + }, 1), + DOMNode::class + ); + } - $this->assertTrue($shouldResolve(function (HtmlQuery $hq) { - }, 0)); - $this->assertTrue($shouldResolve(function ($index, HtmlQuery $hq) { - }, 1)); + public function testClosureResolve() + { + $html = ' +

    foo

    +
    bar
    +
    apple
    +
    orange
    +
    banana
    + '; - $this->assertFalse($shouldResolve(function ($hq) { - }, 0)); - $this->assertFalse($shouldResolve(function ($index, DOMNode $hq) { - }, 1)); + $hq = HQ::html($html)->find('#foo'); + + $closureResolve = $this->protectMethod($hq, 'closureResolve'); + + + $this->assertInstanceOf( + HtmlQuery::class, + $closureResolve(HtmlQuery::class, $hq[0]) + ); + + $this->assertInstanceOf( + HtmlElement::class, + $closureResolve(HtmlElement::class, $hq[0]) + ); + + $this->assertInstanceOf( + DOMNode::class, + $closureResolve('', $hq[0]) + ); } } diff --git a/tests/SelectorTest.php b/tests/SelectorTest.php index 31dc443..1f75f28 100644 --- a/tests/SelectorTest.php +++ b/tests/SelectorTest.php @@ -34,17 +34,17 @@ public function testQuery() $p = $hq->find('p'); $this->assertEquals( '

    NewHtml Query2

    ', - $hq($p)->eq(1)->outerHtml() + ($hq('div')($p))->eq(1)->outerHtml() ); $this->assertEquals( '

    NewHtml Query2

    ', - $hq($p->eq(1)->toArray())->outerHtml() + ($hq('div')($p->eq(1)->toArray()))->outerHtml() ); $this->assertEquals( '

    NewHtml Query

    ', - $hq($p[0])->outerHtml() + ($hq('div')($p[0]))->outerHtml() ); } @@ -85,7 +85,7 @@ public function testFind()

    Have fun

    -

    Thanks

    +

    Thanks

    '; $hq = HQ::html($html); @@ -100,6 +100,11 @@ public function testFind() $hq->find('img.bar')->count() ); + $this->assertEquals( + '

    Thanks

    ', + $hq->find('.content')->find('#ts')->outerHtml() + ); + $html = '
    @@ -886,20 +891,18 @@ public function testInvoke() ); $this->assertEquals( - ($this->protectMethod($hq, 'htmlResolve')( - '

    test

    ' - ))->outerHtml(), - $hq('

    test

    ')->outerHtml() + $hq->query('

    test

    ')->outerHtml(), + ($hq('.content')('

    test

    '))->outerHtml() ); $this->assertEquals( 2, - $hq($hq->find('.bar')->toArray())->count() + ($hq('.content')($hq->find('.bar')->toArray()))->count() ); $exception = null; try { - $hq($this); + $hq('.content')($this); } catch (Exception $exception) { } $this->assertInstanceOf(Exception::class, $exception);