diff --git a/demo.php b/demo.php index d942ea8..98ea798 100644 --- a/demo.php +++ b/demo.php @@ -1,6 +1,7 @@ setAttribute('href', 'https://github.com/webfactory/html5-tagrewriter'); - $element->textContent = 'check this out'; + assert($node instanceof Element); + $node->setAttribute('href', 'https://github.com/webfactory/html5-tagrewriter'); + $node->textContent = 'check this out'; } } diff --git a/psalm-stubs/php_dom.stub.php b/psalm-stubs/php_dom.stub.php new file mode 100644 index 0000000..68d0fc7 --- /dev/null +++ b/psalm-stubs/php_dom.stub.php @@ -0,0 +1,2174 @@ + */ + public function getInScopeNamespaces(): array {} + + /** @return list */ + public function getDescendantNamespaces(): array {} + + public function rename(?string $namespaceURI, string $qualifiedName): void {} + } + + class HTMLElement extends Element + { + } + + class Attr extends Node + { + /** + * @readonly + * @virtual + */ + public ?string $namespaceURI; + /** + * @readonly + * @virtual + */ + public ?string $prefix; + /** + * @readonly + * @virtual + */ + public string $localName; + /** + * @readonly + * @virtual + */ + public string $name; + /** @virtual */ + public string $value; + + /** + * @readonly + * @virtual + */ + public ?Element $ownerElement; + + /** + * @readonly + * @virtual + */ + public bool $specified; + + /** @implementation-alias DOMAttr::isId */ + public function isId(): bool {} + + /** @implementation-alias Dom\Element::rename */ + public function rename(?string $namespaceURI, string $qualifiedName): void {} + } + + class CharacterData extends Node implements ChildNode + { + /** + * @readonly + * @virtual + */ + public ?Element $previousElementSibling; + /** + * @readonly + * @virtual + */ + public ?Element $nextElementSibling; + + /** @virtual */ + public string $data; + /** + * @readonly + * @virtual + */ + public int $length; + /** @implementation-alias DOMCharacterData::substringData */ + public function substringData(int $offset, int $count): string {} + public function appendData(string $data): void {} + public function insertData(int $offset, string $data): void {} + public function deleteData(int $offset, int $count): void {} + public function replaceData(int $offset, int $count, string $data): void {} + + /** @implementation-alias DOMElement::remove */ + public function remove(): void {} + /** @implementation-alias DOMElement::before */ + public function before(Node|string ...$nodes): void {} + /** @implementation-alias DOMElement::after */ + public function after(Node|string ...$nodes): void {} + /** @implementation-alias DOMElement::replaceWith */ + public function replaceWith(Node|string ...$nodes): void {} + } + + class Text extends CharacterData + { + /* No constructor because Node has a final private constructor, so PHP does not allow overriding that. */ + + /** @implementation-alias DOMText::splitText */ + public function splitText(int $offset): Text {} + /** + * @readonly + * @virtual + */ + public string $wholeText; + } + + class CDATASection extends Text {} + + class ProcessingInstruction extends CharacterData + { + /** + * @readonly + * @virtual + */ + public string $target; + } + + class Comment extends CharacterData + { + /* No constructor because Node has a final private constructor, so PHP does not allow overriding that. */ + } + + class DocumentType extends Node implements ChildNode + { + /** + * @readonly + * @virtual + */ + public string $name; + /** + * @readonly + * @virtual + */ + public DtdNamedNodeMap $entities; + /** + * @readonly + * @virtual + */ + public DtdNamedNodeMap $notations; + /** + * @readonly + * @virtual + */ + public string $publicId; + /** + * @readonly + * @virtual + */ + public string $systemId; + /** + * @readonly + * @virtual + */ + public ?string $internalSubset; + + /** @implementation-alias DOMElement::remove */ + public function remove(): void {} + /** @implementation-alias DOMElement::before */ + public function before(Node|string ...$nodes): void {} + /** @implementation-alias DOMElement::after */ + public function after(Node|string ...$nodes): void {} + /** @implementation-alias DOMElement::replaceWith */ + public function replaceWith(Node|string ...$nodes): void {} + } + + class DocumentFragment extends Node implements ParentNode + { + /** + * @readonly + * @virtual + */ + public ?Element $firstElementChild; + /** + * @readonly + * @virtual + */ + public ?Element $lastElementChild; + /** + * @readonly + * @virtual + */ + public int $childElementCount; + + /** @implementation-alias DOMDocumentFragment::appendXML */ + public function appendXml(string $data): bool {} + /** @implementation-alias DOMElement::append */ + public function append(Node|string ...$nodes): void {} + /** @implementation-alias DOMElement::prepend */ + public function prepend(Node|string ...$nodes): void {} + /** @implementation-alias DOMElement::replaceChildren */ + public function replaceChildren(Node|string ...$nodes): void {} + + /** @implementation-alias Dom\Element::querySelector */ + public function querySelector(string $selectors): ?Element {} + /** @implementation-alias Dom\Element::querySelectorAll */ + public function querySelectorAll(string $selectors): NodeList {} + } + + class Entity extends Node + { + /** + * @readonly + * @virtual + */ + public ?string $publicId; + /** + * @readonly + * @virtual + */ + public ?string $systemId; + /** + * @readonly + * @virtual + */ + public ?string $notationName; + } + + class EntityReference extends Node {} + + class Notation extends Node + { + /** + * @readonly + * @virtual + */ + public string $publicId; + /** + * @readonly + * @virtual + */ + public string $systemId; + } + + abstract class Document extends Node implements ParentNode + { + /** @readonly */ + public Implementation $implementation; + /** @virtual */ + public string $URL; + /** @virtual */ + public string $documentURI; + /** @virtual */ + public string $characterSet; + /** @virtual */ + public string $charset; + /** @virtual */ + public string $inputEncoding; + + /** + * @readonly + * @virtual + */ + public ?DocumentType $doctype; + /** + * @readonly + * @virtual + */ + public ?Element $documentElement; + /** @implementation-alias Dom\Element::getElementsByTagName */ + public function getElementsByTagName(string $qualifiedName): HTMLCollection {} + /** @implementation-alias Dom\Element::getElementsByTagNameNS */ + public function getElementsByTagNameNS(?string $namespace, string $localName): HTMLCollection {} + + public function createElement(string $localName): Element {} + public function createElementNS(?string $namespace, string $qualifiedName): Element {} + /** @implementation-alias DOMDocument::createDocumentFragment */ + public function createDocumentFragment(): DocumentFragment {} + /** @implementation-alias DOMDocument::createTextNode */ + public function createTextNode(string $data): Text {} + /** @implementation-alias DOMDocument::createCDATASection */ + public function createCDATASection(string $data): CDATASection {} + /** @implementation-alias DOMDocument::createComment */ + public function createComment(string $data): Comment {} + public function createProcessingInstruction(string $target, string $data): ProcessingInstruction {} + + public function importNode(?Node $node, bool $deep = false): Node {} + public function adoptNode(Node $node): Node {} + + /** @implementation-alias DOMDocument::createAttribute */ + public function createAttribute(string $localName): Attr {} + /** @implementation-alias DOMDocument::createAttributeNS */ + public function createAttributeNS(?string $namespace, string $qualifiedName): Attr {} + + /** + * @readonly + * @virtual + */ + public ?Element $firstElementChild; + /** + * @readonly + * @virtual + */ + public ?Element $lastElementChild; + /** + * @readonly + * @virtual + */ + public int $childElementCount; + + /** @implementation-alias DOMDocument::getElementById */ + public function getElementById(string $elementId): ?Element {} + + public function registerNodeClass(string $baseClass, ?string $extendedClass): void {} + +#ifdef LIBXML_SCHEMAS_ENABLED + /** @implementation-alias DOMDocument::schemaValidate */ + public function schemaValidate(string $filename, int $flags = 0): bool {} + /** @implementation-alias DOMDocument::schemaValidateSource */ + public function schemaValidateSource(string $source, int $flags = 0): bool {} + /** @implementation-alias DOMDocument::relaxNGValidate */ + public function relaxNgValidate(string $filename): bool {} + /** @implementation-alias DOMDocument::relaxNGValidateSource */ + public function relaxNgValidateSource(string $source): bool {} +#endif + + /** @implementation-alias DOMElement::append */ + public function append(Node|string ...$nodes): void {} + /** @implementation-alias DOMElement::prepend */ + public function prepend(Node|string ...$nodes): void {} + /** @implementation-alias DOMDocument::replaceChildren */ + public function replaceChildren(Node|string ...$nodes): void {} + + public function importLegacyNode(\DOMNode $node, bool $deep = false): Node {} + + /** @implementation-alias Dom\Element::querySelector */ + public function querySelector(string $selectors): ?Element {} + /** @implementation-alias Dom\Element::querySelectorAll */ + public function querySelectorAll(string $selectors): NodeList {} + + /** @virtual */ + public ?HTMLElement $body; + /** + * @readonly + * @virtual + */ + public ?HTMLElement $head; + /** @virtual */ + public string $title; + } + + final class HTMLDocument extends Document + { + public static function createEmpty(string $encoding = "UTF-8"): HTMLDocument {} + + public static function createFromFile(string $path, int $options = 0, ?string $overrideEncoding = null): HTMLDocument {} + + public static function createFromString(string $source, int $options = 0, ?string $overrideEncoding = null): HTMLDocument {} + + /** @implementation-alias Dom\XMLDocument::saveXml */ + public function saveXml(?Node $node = null, int $options = 0): string|false {} + + /** @implementation-alias DOMDocument::save */ + public function saveXmlFile(string $filename, int $options = 0): int|false {} + + public function saveHtml(?Node $node = null): string {} + + public function saveHtmlFile(string $filename): int|false {} + +#if ZEND_DEBUG + public function debugGetTemplateCount(): int {} +#endif + } + + final class XMLDocument extends Document + { + public static function createEmpty(string $version = "1.0", string $encoding = "UTF-8"): XMLDocument {} + + public static function createFromFile(string $path, int $options = 0, ?string $overrideEncoding = null): XMLDocument {} + + public static function createFromString(string $source, int $options = 0, ?string $overrideEncoding = null): XMLDocument {} + + /** + * @readonly + * @virtual + */ + public string $xmlEncoding; + + /** @virtual */ + public bool $xmlStandalone; + + /** @virtual */ + public string $xmlVersion; + + /** @virtual */ + public bool $formatOutput; + + /** @implementation-alias DOMDocument::createEntityReference */ + public function createEntityReference(string $name): EntityReference {} + + /** @implementation-alias DOMDocument::validate */ + public function validate(): bool {} + + public function xinclude(int $options = 0): int {} + + public function saveXml(?Node $node = null, int $options = 0): string|false {} + + /** @implementation-alias DOMDocument::save */ + public function saveXmlFile(string $filename, int $options = 0): int|false {} + } + + /** + * @not-serializable + * @strict-properties + */ + final class TokenList implements IteratorAggregate, Countable + { + /** @implementation-alias Dom\Node::__construct */ + private function __construct() {} + + /** + * @readonly + * @virtual + */ + public int $length; + public function item(int $index): ?string {} + public function contains(string $token): bool {} + public function add(string ...$tokens): void {} + public function remove(string ...$tokens): void {} + public function toggle(string $token, ?bool $force = null): bool {} + public function replace(string $token, string $newToken): bool {} + public function supports(string $token): bool {} + /** @virtual */ + public string $value; + + public function count(): int {} + + public function getIterator(): \Iterator {} + } + + /** + * @not-serializable + * @strict-properties + */ + readonly final class NamespaceInfo + { + public ?string $prefix; + public ?string $namespaceURI; + public Element $element; + + /** @implementation-alias Dom\Node::__construct */ + private function __construct() {} + } + +#ifdef LIBXML_XPATH_ENABLED + /** @not-serializable */ + final class XPath + { + /** + * @readonly + * @virtual + */ + public Document $document; + + /** @virtual */ + public bool $registerNodeNamespaces; + + public function __construct(Document $document, bool $registerNodeNS = true) {} + + public function evaluate(string $expression, ?Node $contextNode = null, bool $registerNodeNS = true): null|bool|float|string|NodeList {} + + public function query(string $expression, ?Node $contextNode = null, bool $registerNodeNS = true): NodeList {} + + /** @implementation-alias DOMXPath::registerNamespace */ + public function registerNamespace(string $prefix, string $namespace): bool {} + + /** @implementation-alias DOMXPath::registerPhpFunctions */ + public function registerPhpFunctions(string|array|null $restrict = null): void {} + + /** @implementation-alias DOMXPath::registerPhpFunctionNS */ + public function registerPhpFunctionNS(string $namespaceURI, string $name, callable $callable): void {} + + /** @implementation-alias DOMXPath::quote */ + public static function quote(string $str): string {} + } +#endif + + function import_simplexml(object $node): Attr|Element {} +} diff --git a/psalm.xml b/psalm.xml index 2bf1f7b..3dc4a7d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -6,6 +6,9 @@ xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" > + + + diff --git a/src/Handler/BaseRewriteHandler.php b/src/Handler/BaseRewriteHandler.php index 7785a24..0ec14e3 100644 --- a/src/Handler/BaseRewriteHandler.php +++ b/src/Handler/BaseRewriteHandler.php @@ -5,7 +5,7 @@ namespace Webfactory\Html5TagRewriter\Handler; use Dom\Document; -use Dom\Element; +use Dom\Node; use Dom\XPath; use Override; use Webfactory\Html5TagRewriter\RewriteHandler; @@ -16,7 +16,7 @@ abstract class BaseRewriteHandler implements RewriteHandler { #[Override] - public function match(Element $element): void + public function match(Node $node): void { } diff --git a/src/Implementation/Html5TagRewriter.php b/src/Implementation/Html5TagRewriter.php index 9412d70..c42f2bf 100644 --- a/src/Implementation/Html5TagRewriter.php +++ b/src/Implementation/Html5TagRewriter.php @@ -48,8 +48,9 @@ public function processBodyFragment(string $html5Fragment): string * placed directly after the `` tag. */ $document = HTMLDocument::createFromString('', overrideEncoding: 'utf-8'); - /** @var \Dom\HTMLElement $container */ $container = $document->body; + assert($container !== null); + $container->innerHTML = $html5Fragment; $this->applyHandlers($document, $container); @@ -65,10 +66,10 @@ private function applyHandlers(Document $document, Node $context): void $xpath->registerNamespace('mathml', 'http://www.w3.org/1998/Math/MathML'); foreach ($this->rewriteHandlers as $handler) { - /** @var iterable */ - $elements = $xpath->query($handler->appliesTo(), $context); - foreach ($elements as $element) { - $handler->match($element); + /** @var iterable $nodeList */ + $nodeList = $xpath->query($handler->appliesTo(), $context); + foreach ($nodeList as $node) { + $handler->match($node); } $handler->afterMatches($document, $xpath); } diff --git a/src/RewriteHandler.php b/src/RewriteHandler.php index d209870..d3314fa 100644 --- a/src/RewriteHandler.php +++ b/src/RewriteHandler.php @@ -4,6 +4,7 @@ use Dom\Document; use Dom\Element; +use Dom\Node; use Dom\XPath; /** @@ -46,9 +47,9 @@ public function appliesTo(): string; * If you need to perform batch operations after all elements have been visited, * collect the elements here and process them in afterMatches(). * - * @param Element $element The DOM element that matched the XPath expression + * @param Node $node The DOM node that matched the XPath expression */ - public function match(Element $element): void; + public function match(Node $node): void; /** * Called once after all matching elements have been passed to match(). diff --git a/tests/Fixtures/TestRewriteHandler.php b/tests/Fixtures/TestRewriteHandler.php index 846071f..5e2df55 100644 --- a/tests/Fixtures/TestRewriteHandler.php +++ b/tests/Fixtures/TestRewriteHandler.php @@ -6,6 +6,7 @@ use Dom\Document; use Dom\Element; +use Dom\Node; use Dom\XPath; use Webfactory\Html5TagRewriter\RewriteHandler; @@ -17,7 +18,7 @@ class TestRewriteHandler implements RewriteHandler private string $xpath; /** @var list */ - public array $matchedElements = []; + public array $matchedNodes = []; public int $matchCallCount = 0; @@ -39,13 +40,13 @@ public function appliesTo(): string return $this->xpath; } - public function match(Element $element): void + public function match(Node $node): void { $this->matchCallCount++; - $this->matchedElements[] = $element; + $this->matchedNodes[] = $node; if ($this->matchCallback !== null) { - ($this->matchCallback)($element); + ($this->matchCallback)($node); } } diff --git a/tests/Implementation/Html5TagRewriterTest.php b/tests/Implementation/Html5TagRewriterTest.php index d13edff..ef0f988 100644 --- a/tests/Implementation/Html5TagRewriterTest.php +++ b/tests/Implementation/Html5TagRewriterTest.php @@ -5,6 +5,8 @@ namespace Webfactory\Html5TagRewriter\Tests\Implementation; use Dom\Element; +use Dom\Node; +use Dom\Text; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -337,6 +339,22 @@ public function handler_can_match_MathML_elements(): void self::assertSame(1, $handler->matchCallCount); } + #[Test] + public function handler_can_match_non_element_nodes(): void + { + $handler = new TestRewriteHandler('//html:p/text()'); + $handler->onMatch(function (Node $node) { + self::assertNotInstanceOf(Element::class, $node); + self::assertInstanceOf(Text::class, $node); + self::assertSame('test', $node->textContent); + }); + + $this->rewriter->register($handler); + $this->rewriter->processBodyFragment('

test

'); + + self::assertSame(1, $handler->matchCallCount); + } + #[Test] public function handler_match_is_called_for_each_matching_element(): void { @@ -346,7 +364,7 @@ public function handler_match_is_called_for_each_matching_element(): void $this->rewriter->processBodyFragment('

1

2

3

4

5

'); self::assertSame(5, $handler->matchCallCount); - self::assertCount(5, $handler->matchedElements); + self::assertCount(5, $handler->matchedNodes); } #[Test]