Element.php

PHP

Path: src/Html/Element.php

<?php

declare(strict_types=1);

namespace mini\Html;

class Element extends Node
{
    private const VOID_ELEMENTS = [
        'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
        'link', 'meta', 'param', 'source', 'track', 'wbr',
    ];

    private const BOOLEAN_ATTRIBUTES = [
        'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked',
        'controls', 'default', 'defer', 'disabled', 'formnovalidate',
        'hidden', 'inert', 'ismap', 'loop', 'multiple', 'muted',
        'nomodule', 'novalidate', 'open', 'playsinline', 'readonly',
        'required', 'reversed', 'selected',
    ];

    /** @var array<string, string|bool> */
    private array $attributes;

    /** @var Node[] */
    private array $children = [];

    private bool $isVoid;

    /**
     * @param string $tag HTML tag name
     * @param array<string, string|bool|null> $attributes Element attributes
     * @param string|Node ...$children Child nodes (strings auto-wrap as Text)
     */
    public function __construct(
        private string $tag,
        array $attributes = [],
        string|Node ...$children,
    ) {
        $this->isVoid = in_array(strtolower($tag), self::VOID_ELEMENTS, true);
        $this->attributes = [];

        foreach ($attributes as $name => $value) {
            if ($value !== null && $value !== false) {
                $this->attributes[$name] = $value;
            }
        }

        foreach ($children as $child) {
            $node = is_string($child) ? new Text($child) : $child;
            $node->parent = $this;
            $this->children[] = $node;
        }
    }

    public function __get(string $name): string|bool|null
    {
        return $this->attributes[$name] ?? null;
    }

    public function __set(string $name, string|bool|null $value): void
    {
        if ($value === null || $value === false) {
            unset($this->attributes[$name]);
        } else {
            $this->attributes[$name] = $value;
        }
    }

    public function __isset(string $name): bool
    {
        return isset($this->attributes[$name]);
    }

    public function __unset(string $name): void
    {
        unset($this->attributes[$name]);
    }

    /**
     * Add children after construction
     */
    public function append(string|Node ...$children): static
    {
        foreach ($children as $child) {
            $node = is_string($child) ? new Text($child) : $child;
            $node->parent = $this;
            $this->children[] = $node;
        }
        return $this;
    }

    public function appendChild(Node $node): Node
    {
        if ($node->parent !== null) {
            $node->parent->removeChild($node);
        }
        $node->parent = $this;
        $this->children[] = $node;
        return $node;
    }

    public function insertBefore(Node $newNode, ?Node $referenceNode): Node
    {
        if ($referenceNode === null) {
            return $this->appendChild($newNode);
        }
        $index = array_search($referenceNode, $this->children, true);
        if ($index === false) {
            throw new \InvalidArgumentException('Reference node is not a child of this element');
        }
        if ($newNode->parent !== null) {
            $newNode->parent->removeChild($newNode);
        }
        $newNode->parent = $this;
        array_splice($this->children, $index, 0, [$newNode]);
        return $newNode;
    }

    public function removeChild(Node $node): Node
    {
        $index = array_search($node, $this->children, true);
        if ($index === false) {
            throw new \InvalidArgumentException('Node is not a child of this element');
        }
        $node->parent = null;
        array_splice($this->children, $index, 1);
        return $node;
    }

    public function contains(Node $other): bool
    {
        if ($other === $this) {
            return true;
        }
        foreach ($this->children as $child) {
            if ($child === $other) {
                return true;
            }
            if ($child instanceof Element && $child->contains($other)) {
                return true;
            }
        }
        return false;
    }

    public function firstChild(): ?Node
    {
        return $this->children[0] ?? null;
    }

    public function lastChild(): ?Node
    {
        return $this->children[count($this->children) - 1] ?? null;
    }

    /** @return Node[] */
    public function childNodes(): array
    {
        return $this->children;
    }

    public function firstElementChild(): ?Element
    {
        foreach ($this->children as $child) {
            if ($child instanceof Element) {
                return $child;
            }
        }
        return null;
    }

    /** @return Element[] */
    public function children(): array
    {
        $elements = [];
        foreach ($this->children as $child) {
            if ($child instanceof Element) {
                $elements[] = $child;
            }
        }
        return $elements;
    }

    /**
     * Render just the opening tag (for begin/end pattern)
     */
    public function renderOpenTag(): string
    {
        return '<' . $this->tag . $this->renderAttributes() . '>';
    }

    public function __toString(): string
    {
        $html = $this->renderOpenTag();

        if ($this->isVoid) {
            return $html;
        }

        foreach ($this->children as $child) {
            $html .= (string) $child;
        }

        return $html . '</' . $this->tag . '>';
    }

    /**
     * @return Element[]
     */
    public function querySelectorAll(string $selector): array
    {
        $parsed = CssSelectorParser::parse($selector);
        $results = [];
        $seen = new \SplObjectStorage();

        foreach ($this->collectDescendants($this) as $descendant) {
            foreach ($parsed as $complex) {
                if ($this->matchesComplexSelector($descendant, $complex)) {
                    if (!$seen->contains($descendant)) {
                        $seen->attach($descendant);
                        $results[] = $descendant;
                    }
                    break;
                }
            }
        }

        return $results;
    }

    public function querySelector(string $selector): ?Element
    {
        $parsed = CssSelectorParser::parse($selector);

        foreach ($this->collectDescendants($this) as $descendant) {
            foreach ($parsed as $complex) {
                if ($this->matchesComplexSelector($descendant, $complex)) {
                    return $descendant;
                }
            }
        }

        return null;
    }

    public function getElementById(string $id): ?Element
    {
        return $this->querySelector('#' . $id);
    }

    /**
     * Test if an element matches a complex selector by walking ancestors right-to-left.
     * Per the DOM spec, the full document hierarchy is considered — ancestors above
     * the base element participate in matching.
     *
     * @param array<int, array{compound: array, combinator: ?string}> $segments
     */
    private function matchesComplexSelector(Element $element, array $segments): bool
    {
        $last = count($segments) - 1;

        if (!$element->matchesCompound($segments[$last]['compound'])) {
            return false;
        }

        $current = $element;
        for ($i = $last - 1; $i >= 0; $i--) {
            $combinator = $segments[$i + 1]['combinator'];

            if ($combinator === '>') {
                $current = $current->parentElement();
                if ($current === null || !$current->matchesCompound($segments[$i]['compound'])) {
                    return false;
                }
            } else {
                // Descendant combinator — walk up until an ancestor matches
                $current = $current->parentElement();
                while ($current !== null) {
                    if ($current->matchesCompound($segments[$i]['compound'])) {
                        break;
                    }
                    $current = $current->parentElement();
                }
                if ($current === null) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Collect all descendant elements in document order (depth-first).
     *
     * @return Element[]
     */
    private function collectDescendants(Element $root): array
    {
        $descendants = [];
        foreach ($root->children as $child) {
            if ($child instanceof Element) {
                $descendants[] = $child;
                array_push($descendants, ...$this->collectDescendants($child));
            }
        }
        return $descendants;
    }

    /**
     * Test if this element matches all simple selectors in a compound.
     *
     * @param array<int, array> $compound
     */
    private function matchesCompound(array $compound): bool
    {
        foreach ($compound as $simple) {
            switch ($simple[0]) {
                case 'universal':
                    break;
                case 'type':
                    if (strtolower($this->tag) !== strtolower($simple[1])) {
                        return false;
                    }
                    break;
                case 'id':
                    if (($this->attributes['id'] ?? null) !== $simple[1]) {
                        return false;
                    }
                    break;
                case 'class':
                    $classes = $this->attributes['class'] ?? '';
                    if (is_bool($classes)) {
                        return false;
                    }
                    if (!preg_match('/(?:^|\s)' . preg_quote($simple[1], '/') . '(?:\s|$)/', $classes)) {
                        return false;
                    }
                    break;
                case 'attr':
                    if (!isset($this->attributes[$simple[1]])) {
                        return false;
                    }
                    if (isset($simple[2])) {
                        $val = $this->attributes[$simple[1]];
                        if (is_bool($val)) {
                            $val = $val ? $simple[1] : '';
                        }
                        if ($val !== $simple[2]) {
                            return false;
                        }
                    }
                    break;
            }
        }
        return true;
    }

    private function renderAttributes(): string
    {
        $html = '';

        foreach ($this->attributes as $name => $value) {
            if ($value === true) {
                if (in_array(strtolower($name), self::BOOLEAN_ATTRIBUTES, true)) {
                    $html .= ' ' . $name;
                } else {
                    $html .= ' ' . $name . '="' . $name . '"';
                }
            } elseif ($value !== false && $value !== null) {
                $html .= ' ' . $name . '="' . htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"';
            }
        }

        return $html;
    }
}