biurad/php-templating

View on GitHub
src/Html/HtmlElement.php

Summary

Maintainability
D
2 days
Test Coverage
B
83%
<?php

declare(strict_types=1);

/*
 * This file is part of Biurad opensource projects.
 *
 * PHP version 7.2 and above required
 *
 * @author    Divine Niiquaye Ibok <divineibok@gmail.com>
 * @copyright 2019 Biurad Group (https://biurad.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Biurad\UI\Html;

use Biurad\UI\Exceptions\RenderException;
use Biurad\UI\Interfaces\HtmlInterface;

/**
 * This class provides a set of static methods for creating php html.
 *
 * @see Biurad\UI\Html\createElement
 * @experimental in 1.0
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
final class HtmlElement
{
    public const HTML_ARRAY_ENCODE = \JSON_UNESCAPED_UNICODE | \JSON_HEX_QUOT | \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS;

    public const HTML_ATTR_ENCODE = \ENT_QUOTES | \ENT_SUBSTITUTE | \ENT_HTML5;

    public const HTML_STRING_ENCODE = \ENT_NOQUOTES | \ENT_SUBSTITUTE;

    public const ATTR_REGEX = '~
        \s*(?<prop>[\w:-]+) ## the HTML attribute name
        (?:
            \=(?|
                \"(?P<value>.*?)\"| ## the double quoted HTML attribute value
                \'(?P<value>.*?)\'| ## the single quoted HTML attribute value
                (?P<value>.*?)(\s+) ## the non quoted HTML attribute value
            )?
        )?
    ~xsi';

    public const HTML_REGEX = '~
        <(?P<tag>\s*[\w\!\-]+) ##  beginning of HTML tag
        \s*(?P<attr>[^>]+)?\> ## the HTML attribute in tag
        #(?:
            #(?P<value>[^\<]+) ## the HTML value
            #?(?P<end>\<\s*\/\w+\>) ##  end of HTML tag
        #)?
    ~xsi';

    /** @var array<string,int> */
    private static $generateIdCounter = [];

    /**
     * Returns an autogenerated sequential ID.
     */
    public static function generateId(string $prefix = 'i'): string
    {
        if (isset(self::$generateIdCounter[$prefix])) {
            $counter = ++self::$generateIdCounter[$prefix];
        } else {
            self::$generateIdCounter = [$prefix => ($counter = 0)];
        }

        if ($counter > 0) {
            $prefix .= $counter;
        }

        return $prefix;
    }

    /**
     * Create an indent string spaced level.
     */
    public static function generateIndent(?int $indentLevel): string
    {
        return null !== $indentLevel ? "\n" . \str_repeat(' ', $indentLevel) : '';
    }

    /**
     * Generates a list of html elements node from html string.
     *
     * @return array<int,Node\AbstractNode>
     */
    public static function generateNodes(string $html): array
    {
        $matchedNodes = $elementNodes = $resolvedNodes = [];
        $html = \preg_replace(['/\n\s*/', '/\s+<\//', '/\"\s+\>/', '/\"\s+\s+/'], [' ', '</', '">', '" '], $html) ?? $html;

        if (!\preg_match_all(self::HTML_REGEX, $html, $matches, \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE)) {
            throw new RenderException('Unable to render provided html into element nodes.');
        }

        foreach ($matches as $match) {
            $attributes = [];
            [$tagOpen, $tagOffset] = $match['tag'];

            if ('!--' === $tagOpen) {
                $matchedNodes[] = new Node\CommentNode($match[0][0]);

                continue;
            }

            if ('' !== $tagAttr = $match['attr'][0] ?? '') {
                \preg_match_all(self::ATTR_REGEX, $tagAttr, $attrMatches, \PREG_SET_ORDER);

                if (!empty($attrMatches)) {
                    foreach ($attrMatches as $attrMatch) {
                        $attributes[] = new Node\AttributeNode($attrMatch['prop'], $attrMatch['value'] ?? null, ' ', isset($attrMatch[3]));
                    }
                }
            }

            if (!\str_ends_with($tag = $match[0][0], '/>')) {
                $htmlPos = $recursive = 0;
                $tagContent = \substr($html, $tagOffset + \strlen($tag) - 1);

                while ($htmlPos < \strlen($tagContent)) {
                    if (\preg_match('/^<\s*' . $tagOpen . '\s*.*?>/', $tagHtml = \substr($tagContent, $htmlPos))) {
                        ++$recursive;
                    }

                    if (\preg_match('/^<\s*\/\s*' . $tagOpen . '\s*>/', $tagHtml)) {
                        if ($recursive > 0) {
                            --$recursive;

                            continue;
                        }

                        $matchedNodes[] = new Node\ElementNode($tagOpen, \substr($tagContent, 0, $htmlPos), $attributes);
                        $tagContent = null;

                        break;
                    }

                    ++$htmlPos;
                }

                if (null === $tagContent) {
                    continue;
                }

                unset($tagContent, $htmlPos);
            } else {
                $attributes[] = new Node\AttributeNode('/', null, ' ' === $match[0][0][-3] ? ' ' : '');
            }

            $matchedNodes[] = new Node\SelfCloseNode($tagOpen, $attributes);
        }

        foreach ($matchedNodes as $offset => $tagNode) {
            if (isset($matchedNodes[$offset + 1])) {
                $tagNode->next = &$matchedNodes[$offset + 1];
            }

            // Add $tagNode to an orderly stack
            self::doParseNode($tagNode, $offset, $elementNodes, $resolvedNodes);
        }

        unset($matchedNodes, $resolvedNodes);

        return $elementNodes;
    }

    /**
     * Parses node generated by the generateNodes() method.
     *
     * @param array<int,Node\AbstractNode> $elementNodes
     * @param callable|null                $resolveNode  Takes two parameters, (Node\AbstractNode &$tagNode, ?int $IndentLevel)
     */
    public static function generateHtml(array $elementNodes, callable $resolveNode = null, array $options = []): string
    {
        $content = '';

        if (null !== ($indentLevel = $options['indentLevel'] ?? null) && $indentLevel < 2) {
            throw new \InvalidArgumentException(\sprintf('Indent level for formatting cannot be less than two: "%s" provided.', $indentLevel));
        }

        $indent = $options['indent'] ?? $indentLevel;

        foreach ($elementNodes as $tagNode) {
            $tagIndent = self::generateIndent($indent ? $indent - $indentLevel : $indent);

            if ($tagNode instanceof Node\ElementNode) {
                $tagHtml = '';

                if (null !== $indent && !empty($tagNode->html)) {
                    if (empty($tagNode->children)) {
                        $tagHtml .= self::generateIndent($indent);
                    }

                    $indent += $indentLevel;
                }

                $tagHtml .= $h = (!empty($tagNode->children) ? self::generateHtml($tagNode->children, $resolveNode, \compact('indentLevel', 'indent')) : $tagNode->html);

                if ($h && $indent) {
                    $tagHtml .= self::generateIndent(($indent -= $indentLevel) - $indentLevel);
                }

                $tagNode->html = $tagHtml;
            }

            $tagNode->indentLevel = $indent;
            $content .= null !== $resolveNode ? $resolveNode($tagNode, $tagIndent, $indentLevel) : ($tagIndent . (string) $tagNode);
        }

        return $content;
    }

    /**
     * Cast html string into object.
     *
     * @param string $html
     *
     * @return HtmlInterface&\IteratorAggregate
     */
    public static function renderHtml(string $html): HtmlInterface
    {
        return new class ($html) implements HtmlInterface, \IteratorAggregate {
            /** @var string */
            private $content;

            public function __construct(string $content)
            {
                $this->content = $content;
            }

            /**
             * {@inheritdoc}
             */
            public function __toString(): string
            {
                return $this->content;
            }

            /**
             * {@inheritdoc}
             *
             * @return \ArrayIterator<int,Node\AbstractNode>
             */
            public function getIterator(): \Traversable
            {
                return new \ArrayIterator(HtmlElement::generateNodes($this->content));
            }
        };
    }

    /**
     * Renders the HTML tag attributes.
     *
     * Attributes with boolean values are rendered without a value. `['class' => true]` as: `class`,
     * array values are json encoded, while null values are ignored.
     *
     * Additionally, attributes prefixed with a "@" symbol are specially rendered when receiving an array value.
     * For example, `'@data' => ['id' => 1, 'name' => 'biurad']` is rendered as `data-id="1" data-name="biurad"`
     *
     * @param array<string,mixed> $attributes
     */
    public static function renderAttributes(array $attributes): string
    {
        $attribute = '';

        foreach ($attributes as $name => $value) {
            if (null === $value || [] === $value || false === $value) {
                continue;
            }

            if (\is_int($name)) {
                $attribute .= ' ' . \htmlspecialchars($value, self::HTML_STRING_ENCODE);
            } elseif (true === $value) {
                $attribute .= ' ' . $name;
            } elseif (\is_array($value)) {
                if ('class' === $name) {
                    $attribute .= ' ' . $name . '="' . \htmlspecialchars(\implode(' ', $value), self::HTML_ATTR_ENCODE) . '"';
                } elseif ('@' === $name[0] || \in_array($name, ['data', 'data-ng', 'ng', 'aria'], true)) {
                    $name = \ltrim($name . '-', '@');

                    foreach ($value as $n => $v) {
                        $attribute .= ' ' . $name . $n . '=';
                        $attribute .= \is_array($v) ? '\'' . \json_encode($v, self::HTML_ARRAY_ENCODE) . '\'' : '"' . \htmlspecialchars($v, self::HTML_ATTR_ENCODE) . '"';
                    }
                } elseif ('style' === $name) {
                    $style = '';
                    $attribute .= ' ' . $name . '="';

                    foreach ($value as $selector => $styling) {
                        $style .= $selector . ': ' . \rtrim($styling, ';') . ';';
                    }

                    $attribute .= \htmlspecialchars($style, self::HTML_ATTR_ENCODE) . '"';
                } else {
                    $attribute .= ' ' . $name . '=\'' . \json_encode($value, self::HTML_ARRAY_ENCODE) . '\'';
                }
            } else {
                $attribute .= ' ' . ('' === $value ? $name : $name . '="' . \htmlspecialchars($value, self::HTML_ATTR_ENCODE) . '"');
            }
        }

        return $attribute;
    }

    /**
     * Converts a CSS style array into a string representation.
     *
     * @param array<string,string> $style The CSS style array. (e.g. `['div' => ['width' => '100px', 'height' => '200px']]`)
     *
     * @return string|null The CSS style string. If the CSS style is empty, a null will be returned.
     */
    public static function cssFromArray(array $style): ?string
    {
        $result = '';

        foreach ($style as $name => $value) {
            if (\is_array($value)) {
                if (\str_starts_with($name, '@')) {
                    $result .= $name . ' { ' . self::cssFromArray($value) . ' } ';
                } elseif (!empty($value)) {
                    $result .= $name . ' { ';

                    foreach ($value as $selector => $styling) {
                        if (\is_array($styling)) {
                            $result .= self::cssFromArray($value);

                            continue;
                        }

                        $result .= $selector . ': ' . \rtrim($styling, ';') . '; ';
                    }

                    $result .= '} ';
                }

                continue;
            }

            $result .= $name . ' { ' . \rtrim($value, ';') . '; } ';
        }

        // Return null if empty to avoid rendering the "style" attribute.
        return '' === $result ? null : \rtrim($result);
    }

    private static function doParseNode(Node\AbstractNode $tagNode, int $offset, array &$elementNodes, array &$resolvedNodes): void
    {
        $lastKey = \array_key_last($elementNodes);
        $previousNode = null !== $lastKey ? $elementNodes[$lastKey] : null;

        if ($previousNode instanceof Node\ElementNode) {
            if ($tagNode instanceof Node\ElementNode) {
                $tagHtml = $tagNode->html;

                if ($tagNode->parent === $previousNode->parent && ('' === $tagHtml && empty($previousNode->children))) {
                    $tagHtml = (string) $tagNode;
                }
            } else {
                $tagHtml = (string) $tagNode;
            }

            if (\str_contains($previousNode->html, $tagHtml)) {
                $tagNode->parent = &$previousNode;
                self::doParseNode($tagNode, $offset, $previousNode->children, $resolvedNodes);
            }
        }

        if (!isset($resolvedNodes[$offset])) {
            $elementNodes[$resolvedNodes[$offset] = $offset] = $tagNode;
        }
    }
}