src/Html/HtmlElement.php
<?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;
}
}
}