src/Parser.php
<?php
declare(strict_types=1);
namespace Faf\TemplateEngine;
use Closure;
use DateTime;
use DateTimeZone;
use DOMNode;
use Faf\TemplateEngine\Elements\Trim;
use Faf\TemplateEngine\Elements\TrimString;
use Faf\TemplateEngine\Elements\TrimCharlist;
use Faf\TemplateEngine\Helpers\DataHelper;
use IntlCalendar;
use IntlDateFormatter;
use IntlTimeZone;
use IvoPetkov\HTML5DOMElement;
use IvoPetkov\HTML5DOMDocument;
use DOMXPath;
use Faf\TemplateEngine\Elements\{Base64Decode,
Base64Encode,
Calc,
Call,
CallFunction,
ConditionalStatement,
ConditionalStatementCondition,
ConditionalStatementConditionAnd,
ConditionalStatementConditionEmpty,
ConditionalStatementConditionEndsNotWith,
ConditionalStatementConditionEndsWith,
ConditionalStatementConditionEqual,
ConditionalStatementConditionFalse,
ConditionalStatementConditionGreaterEqualThan,
ConditionalStatementConditionGreaterThan,
ConditionalStatementConditionLessEqualThan,
ConditionalStatementConditionLessThan,
ConditionalStatementConditionNotEmpty,
ConditionalStatementConditionOr,
ConditionalStatementConditionStartsNotWith,
ConditionalStatementConditionStartsWith,
ConditionalStatementConditionTrue,
ConditionalStatementConditionTypeSafeEqual,
ConditionalStatementConditionTypeSafeNotEqual,
ConditionalStatementElse,
ConditionalStatementThen,
ConditionalStatementConditionNotEqual,
FormatAsDate,
FormatAsDateFormat,
FormatAsDateString,
FormatAsDatetime,
FormatAsDatetimeFormat,
FormatAsDatetimeString,
FormatAsShortSize,
FormatAsShortSizeDecimals,
FormatAsShortSizeValue,
FormatAsTime,
FormatAsTimeFormat,
FormatAsTimeString,
Get,
GetFormat,
Htmlentities,
HtmlentitiesString,
HtmlEntityDecode,
HtmlEntityDecodeString,
Htmlspecialchars,
HtmlspecialcharsDecode,
HtmlspecialcharsDecodeString,
HtmlspecialcharsString,
JsonDecode,
JsonEncode,
Loop,
LoopAs,
LoopBody,
LoopEach,
Nl2Br,
Nl2BrString,
Param,
ParamName,
ParamValue,
Parse,
ParseString,
Round,
RoundValue,
Set,
StripTags,
StripTagsAllowableTags,
StripTagsString,
StrReplace,
StrReplaceSubject,
StrToLower,
StrToLowerString,
StrToUpper,
StrToUpperString,
Tag,
TagAttribute,
TagAttributeEmpty,
TagAttributeName,
TagAttributeValue,
TagBody,
TagName,
TimeTag,
UcWords,
UcWordsDelimiters,
UcWordsString,
VarDump};
use Faf\TemplateEngine\Helpers\BaseObject;
use Faf\TemplateEngine\Helpers\ElementSetting;
use Faf\TemplateEngine\Helpers\ParserElement;
use JsonException;
use Locale;
use NumberFormatter;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
use Exception;
use ErrorException;
use RuntimeException;
use Yiisoft\Validator\Rules;
class Parser extends BaseObject
{
public const ROOT = 'root';
public const MODE_PROD = 0;
public const MODE_DEV = 1;
public const TYPE_HTML = 0;
public const TYPE_TEXT = 1;
protected const FORMAT_TYPE_DATE_TIME = 1;
protected const FORMAT_TYPE_DATE = 2;
protected const FORMAT_TYPE_TIME = 3;
//region properties
/**
* @var string
*/
public string $name = 'fafte';
/**
* @var string[]
*/
protected array $elements = [
Base64Encode::class,
Call::class,
CallFunction::class,
FormatAsDate::class,
FormatAsDateFormat::class,
FormatAsDateString::class,
FormatAsDatetime::class,
FormatAsDatetimeFormat::class,
FormatAsDatetimeString::class,
FormatAsShortSize::class,
FormatAsShortSizeDecimals::class,
FormatAsShortSizeValue::class,
FormatAsTime::class,
FormatAsTimeFormat::class,
FormatAsTimeString::class,
Get::class,
GetFormat::class,
JsonEncode::class,
JsonDecode::class,
Param::class,
ParamName::class,
ParamValue::class,
Set::class,
StripTags::class,
StripTagsAllowableTags::class,
StripTagsString::class,
StrToLower::class,
StrToLowerString::class,
StrToUpper::class,
StrToUpperString::class,
UcWords::class,
UcWordsDelimiters::class,
UcWordsString::class,
Trim::class,
TrimString::class,
TrimCharlist::class,
Nl2Br::class,
Nl2BrString::class,
Htmlentities::class,
HtmlentitiesString::class,
HtmlEntityDecode::class,
HtmlEntityDecodeString::class,
Htmlspecialchars::class,
HtmlspecialcharsString::class,
HtmlspecialcharsDecode::class,
HtmlspecialcharsDecodeString::class,
Parse::class,
ParseString::class,
RoundValue::class,
Round::class,
VarDump::class,
StrReplace::class,
StrReplaceSubject::class,
TimeTag::class,
Base64Decode::class,
ConditionalStatement::class,
ConditionalStatementConditionNotEqual::class,
ConditionalStatementConditionEqual::class,
ConditionalStatementConditionTypeSafeEqual::class,
ConditionalStatementConditionTypeSafeNotEqual::class,
ConditionalStatementConditionEndsWith::class,
ConditionalStatementConditionEndsNotWith::class,
ConditionalStatementConditionStartsWith::class,
ConditionalStatementConditionStartsNotWith::class,
ConditionalStatementConditionTrue::class,
ConditionalStatementConditionFalse::class,
ConditionalStatementConditionEmpty::class,
ConditionalStatementConditionNotEmpty::class,
ConditionalStatementConditionLessThan::class,
ConditionalStatementConditionLessEqualThan::class,
ConditionalStatementConditionGreaterThan::class,
ConditionalStatementConditionGreaterEqualThan::class,
ConditionalStatementThen::class,
ConditionalStatementElse::class,
ConditionalStatementCondition::class,
ConditionalStatementConditionAnd::class,
ConditionalStatementConditionOr::class,
Loop::class,
LoopBody::class,
LoopEach::class,
LoopAs::class,
Tag::class,
TagName::class,
TagBody::class,
TagAttribute::class,
TagAttributeName::class,
TagAttributeValue::class,
TagAttributeEmpty::class,
Calc::class
];
/**
* @var LoggerInterface|null
*/
public ?LoggerInterface $logger = null;
/**
* @var CacheInterface|null
*/
protected ?CacheInterface $cache = null;
protected int $cacheTtl = 3600;
protected int $mode = self::MODE_PROD;
protected int $type = self::TYPE_HTML;
protected int $maxDeep = 100;
/**
* @var array<string|int, array|string|int|float|bool|object>
*/
public array $data = [];
/**
* @var string|null
*/
protected ?string $language = null;
/**
* @var string|null
*/
private ?string $currentLanguage = null;
/**
* @var float
*/
protected float $debugStartTime;
/**
* @var bool
*/
protected bool $returnRawData = false;
/**
* @var int
*/
protected int $currentDeep = 0;
/**
* @var array
*/
protected array $nodeStats = [];
/**
* @var array
*/
protected array $parserElements;
/**
* @var ParserElement[]
*/
protected array $parserElementsByClassName;
/**
* @var string
*/
protected string $tempTagName;
/**
* @var array
*/
protected array $bootstrapCache = [];
/**
* @var string
*/
protected string $currentTagName = self::ROOT;
/**
* @var string
*/
protected string $parentTagName = self::ROOT;
/**
* @var array
*/
protected array $allowedChildElements = [];
/**
* @var HTML5DOMDocument
*/
protected HTML5DOMDocument $htmlTagDom;
/**
* @var array
*/
protected array $settings = [];
//endregion properties
//region getter and setter
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
*
* @return $this
*/
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
/**
* @return string[]
*/
public function getElements(): array
{
return $this->elements;
}
/**
* @param string[] $elements
*
* @return $this
*/
public function setElements(array $elements): self
{
$this->elements = $elements;
$this->refresh();
return $this;
}
/**
* @param string[] $elements
*
* @return $this
*/
public function addElements(array $elements): self
{
$this->elements = array_merge($this->elements, $elements);
$this->refresh();
return $this;
}
/**
* @return int
*/
public function getCurrentDeep(): int
{
return $this->currentDeep;
}
/**
* @return string
*/
public function getCurrentTagName(): string
{
return $this->currentTagName;
}
/**
* @return int
*/
public function getType(): int
{
return $this->type;
}
/**
* @param int $type
*
* @return $this
*/
public function setType(int $type): self
{
$this->type = $type;
$this->refresh();
return $this;
}
/**
* @return string|null
*/
public function getLanguage(): ?string
{
if ($this->language === null) {
$this->language = Locale::getDefault();
}
return $this->language;
}
/**
* @param string|null $language
*
* @return $this
*/
public function setLanguage(?string $language): self
{
$this->language = $language;
if ($this->language !== null) {
$this->language = strtolower($this->language);
}
return $this;
}
/**
* @param array<string|int, array|string|int|float|bool|object> $data
*
* @return $this
*/
public function setData(array &$data): self
{
$this->data = &$data;
return $this;
}
/**
* @param LoggerInterface|null $logger
*
* @return $this
*/
public function setLogger(?LoggerInterface $logger): self
{
$this->logger = $logger;
if ($this->logger === null) {
$this->logger = new NullLogger();
}
return $this;
}
/**
* @return LoggerInterface
*/
public function getLogger(): LoggerInterface
{
if ($this->logger === null) {
$this->logger = new NullLogger();
}
return $this->logger;
}
/**
* @param CacheInterface|null $cache
*
* @return $this
*/
public function setCache(?CacheInterface $cache): self
{
$this->cache = $cache;
return $this;
}
/**
* @param int $mode
*
* @return $this
*/
public function setMode(int $mode): self
{
$this->mode = $mode;
return $this;
}
/**
* @return bool
*/
public function getReturnRawData(): bool
{
return $this->returnRawData;
}
/**
* @param bool $returnRawData
*
* @return Parser
*/
public function setReturnRawData(bool $returnRawData): self
{
$this->returnRawData = $returnRawData;
return $this;
}
/**
* @return array
*/
public function getNodeStats(): array
{
return $this->nodeStats;
}
/**
* @return array
*/
public function getSettings(): array
{
return $this->settings;
}
/**
* @param array $settings
*
* @return Parser
*/
public function setSettings(array $settings): self
{
$this->settings = $settings;
return $this;
}
/**
* @param array $settings
*
* @return Parser
*/
public function addSettings(array $settings): self
{
$this->settings = array_merge($this->settings, $settings);
return $this;
}
/**
* @param string $name
*
* @return mixed|null
*/
public function getSetting(string $name)
{
return $this->settings[$name] ?? null;
}
/**
* @param string $name
* @param $value
*
* @return Parser
*/
public function setSetting(string $name, $value): self
{
$this->settings[$name] = $value;
return $this;
}
//endregion getter and setter
//region init
protected function init(): void
{
if ($this->logger === null) {
$this->logger = new NullLogger();
}
$this->refresh();
}
public function refresh(): void
{
if ($this->mode === self::MODE_DEV) {
$this->debugStartTime = microtime(true);
}
$debugId = $this->debugStart('Refresh');
$elementClasses = $this->elements;
foreach ($elementClasses as $elementClass) {
$parserElement = new $elementClass([
'parser' => $this
]);
foreach ($parserElement->tagNameAliases() as $tagNameAlias) {
$this->parserElements[$tagNameAlias] = $parserElement;
}
$this->parserElements[$parserElement->tagName()] = $parserElement;
$this->parserElementsByClassName[$elementClass] = $parserElement;
}
foreach ($this->parserElements as $currentTagName => $parserElement) {
if (!isset($this->bootstrapCache[$parserElement->name()])) {
$parserElement->bootstrap();
$this->bootstrapCache[$parserElement->name()] = true;
}
}
$this->allowedChildElements = $this->loadAllowedChildElements();
$this->tempTagName = 'temp-tag-' . $this->name . '-temp-tag';
$this->specialTagMap = [
'<body' => '<' . $this->tempTagName . '-body',
'</body' => '</' . $this->tempTagName . '-body',
'<head' => '<' . $this->tempTagName . '-head',
'</head' => '</' . $this->tempTagName . '-head',
'<html' => '<' . $this->tempTagName . '-html',
'</html' => '</' . $this->tempTagName . '-html',
];
$this->htmlTagDom = new HTML5DOMDocument();
$this->debugEnd($debugId);
}
//endregion init
//region child elements
protected function loadAllowedChildElements(): array
{
$debugId = $this->debugStart('Load allowed child elements');
$key = 'fafte-allowed-child-elements-' . md5(implode('', array_keys($this->parserElements)));
$allowedChildElements = null;
if ($this->cache !== null) {
try {
$allowedChildElements = $this->cache->get($key);
} catch (InvalidArgumentException $e) {
}
}
if ($allowedChildElements === null) {
$allowedChildElements = [];
foreach ($this->parserElements as $currentTagName => $parserElement) {
$allowedTypes = $parserElement->allowedTypes();
if ($allowedTypes === null) {
$allowedTypes = [''];
}
foreach ($allowedTypes as $allowedType) {
if (!isset($allowedChildElements[$allowedType][$currentTagName])) {
$allowedChildElements[$allowedType][$currentTagName] = [];
}
$allowedParents = $parserElement->allowedParents();
if ($allowedParents === null) {
$allowedParents = [''];
}
foreach ($allowedParents as $allowedParent) {
if ($allowedParent === '') {
$allowedChildElements[$allowedType][''][] = $currentTagName;
} elseif ($allowedParent === self::ROOT) {
$allowedChildElements[$allowedType][self::ROOT][] = $currentTagName;
} elseif (isset($this->parserElementsByClassName[$allowedParent])) {
$parentElement = $this->parserElementsByClassName[$allowedParent];
foreach ($parentElement->tagNameAliases() as $tagNameAlias) {
$allowedChildElements[$allowedType][$tagNameAlias][] = $currentTagName;
}
$allowedChildElements[$allowedType][$parentElement->tagName()][] = $currentTagName;
}
}
}
}
if ($this->cache !== null) {
try {
$this->cache->set($key, $allowedChildElements, $this->cacheTtl);
} catch (InvalidArgumentException $e) {
}
}
}
$this->debugEnd($debugId);
return $allowedChildElements;
}
/**
* @param int $type
* @param string $tagName
*
* @return array
*/
protected function getAllowedChildElements(int $type, string $tagName): array
{
$debugId = $this->debugStart('Get allowed child elements for ' . $tagName . ' of type ' . $type);
$allowedChildElementsByType = array_merge_recursive(
$this->allowedChildElements[''] ?? [],
$this->allowedChildElements[$type] ?? []
);
$allowedChildElementsByTag = array_merge_recursive(
$allowedChildElementsByType[''] ?? [],
$allowedChildElementsByType[$tagName] ?? []
);
$this->debugEnd($debugId);
return $allowedChildElementsByTag;
}
//endregion child elements
//region formatter and helper
/**
* @param int $type
* @param DateTime|string $dateTime
* @param string|int $format
* @param IntlTimeZone|DateTimeZone|null $inputTimeZone
* @param IntlTimeZone|DateTimeZone|null $timeZone
*
* @return false|string
*/
protected function dateTimeFormatter(int $type, $dateTime, $format, $inputTimeZone = null, $timeZone = null)
{
if ($inputTimeZone === null) {
$inputTimeZone = new DateTimeZone(date_default_timezone_get());
}
if ($timeZone === null) {
$timeZone = new DateTimeZone(date_default_timezone_get());
}
if (is_string($dateTime)) {
$dateTime = new DateTime($dateTime, $inputTimeZone);
}
$calendar = IntlCalendar::fromDateTime($dateTime);
$calendar->setTimeZone($timeZone);
$dateType = IntlDateFormatter::NONE;
$timeType = IntlDateFormatter::NONE;
$intlFormat = '';
$useFormatAsType = false;
if ($format === null) {
$format = '';
} elseif (is_int($format)) {
$useFormatAsType = true;
}
if ($type === self::FORMAT_TYPE_DATE_TIME) {
/**
* @var int $format
*/
$dateType = $useFormatAsType ? $format : IntlDateFormatter::MEDIUM;
$timeType = $useFormatAsType ? $format : IntlDateFormatter::MEDIUM;
} elseif ($type === self::FORMAT_TYPE_DATE) {
/**
* @var int $format
*/
$dateType = $useFormatAsType ? $format : IntlDateFormatter::MEDIUM;
} elseif ($type === self::FORMAT_TYPE_TIME) {
/**
* @var int $format
*/
$timeType = $useFormatAsType ? $format : IntlDateFormatter::MEDIUM;
}
if (!$useFormatAsType) {
/**
* @var string $format
*/
$intlFormat = $format;
}
$df = new IntlDateFormatter($this->currentLanguage, $dateType, $timeType, $timeZone, $calendar, $intlFormat);
return $df->format($calendar);
}
/**
* @param DateTime|string $dateTime
* @param string|int $format
* @param IntlTimeZone|DateTimeZone|null $inputTimeZone
* @param IntlTimeZone|DateTimeZone|null $timeZone
*
* @return false|string
*/
public function formatDateTime($dateTime, $format, $inputTimeZone = null, $timeZone = null)
{
return $this->dateTimeFormatter(self::FORMAT_TYPE_DATE_TIME, $dateTime, $format, $inputTimeZone, $timeZone);
}
/**
* @param DateTime|string $time
* @param string|int $format
* @param IntlTimeZone|DateTimeZone|null $inputTimeZone
* @param IntlTimeZone|DateTimeZone|null $timeZone
*
* @return false|string
*/
public function formatTime($time, $format, $inputTimeZone = null, $timeZone = null)
{
return $this->dateTimeFormatter(self::FORMAT_TYPE_TIME, $time, $format, $inputTimeZone, $timeZone);
}
/**
* @param DateTime|string $date
* @param string|int $format
* @param IntlTimeZone|DateTimeZone|null $inputTimeZone
* @param IntlTimeZone|DateTimeZone|null $timeZone
*
* @return false|string
*/
public function formatDate($date, $format, $inputTimeZone = null, $timeZone = null)
{
return $this->dateTimeFormatter(self::FORMAT_TYPE_DATE, $date, $format, $inputTimeZone, $timeZone);
}
/**
* @param $number
* @param int $style
* @param string $pattern
* @param array $attributes
* @param array $symbols
* @param array $textAttributes
*
* @return false|string
*/
public function formatNumber(
$number,
int $style,
string $pattern = '',
array $attributes = [],
array $symbols = [],
array $textAttributes = []
) {
$numberFormatter = new NumberFormatter($this->currentLanguage, $style, $pattern);
foreach ($attributes as $name => $value) {
$numberFormatter->setAttribute($name, $value);
}
foreach ($symbols as $name => $value) {
$numberFormatter->setSymbol($name, $value);
}
foreach ($textAttributes as $name => $value) {
$numberFormatter->setTextAttribute($name, $value);
}
return $numberFormatter->format($number);
}
/**
* @param string $name
* @param string $content
* @param array<string|int, array|string|int|float|bool|object>|null $options
* @param string $attributePrefix
*
* @return string
* @throws JsonException
*/
public function htmlTag(
string $name,
string $content = '',
?array $options = [],
string $attributePrefix = ''
): string {
/**
* @var HTML5DOMElement $element
*/
$element = $this->htmlTagDom->createElement($name);
$element->innerHTML = $content;
foreach ($options as $attribute => $value) {
if ($value instanceof DataHelper) {
if (!$value->keepEmpty && $value->value === '') {
continue;
}
$attribute = $value->name;
$value = $value->value;
} else {
if ($value === '') {
continue;
}
if ($attributePrefix !== '' && mb_strpos($attribute, $attributePrefix) === 0) {
$attribute = mb_substr($attribute, mb_strlen($attributePrefix));
}
}
if (is_array($value) || is_object($value)) {
$value = json_encode(
$value,
JSON_UNESCAPED_UNICODE |
JSON_HEX_QUOT |
JSON_HEX_TAG |
JSON_HEX_AMP |
JSON_HEX_APOS |
JSON_THROW_ON_ERROR,
512
);
}
if ($value === true) {
$value = 'true';
} elseif ($value === false) {
$value = 'false';
}
$element->setAttribute($attribute, (string)$value);
}
return $element->outerHTML;
}
//endregion formatter and helper
/**
* @param $string
*
* @return array|object|string
* @throws Exception
*/
public function parse($string)
{
$debugId = $this->debugStart('Parse');
$result = $this->parseElements($string, self::ROOT, $this->returnRawData);
if (!$this->returnRawData && is_string($result)) {
$result = str_ireplace(
['<' . $this->tempTagName . '-special>', '</' . $this->tempTagName . '-special>'],
['<!', '>'],
$result
);
$result = str_ireplace(
array_values($this->specialTagMap),
array_keys($this->specialTagMap),
$result
);
}
$this->debugEnd($debugId);
return $result;
}
/**
* @param string $string
* @param string $currentTagName
* @param bool $rawData
*
* @return array|object|string
* @throws Exception
*/
public function parseElements(string $string, string $currentTagName, bool $rawData = false)
{
$parentLanguage = $this->currentLanguage;
$this->currentLanguage = $this->getLanguage();
$parentTagName = $this->parentTagName;
$this->parentTagName = $this->currentTagName;
$this->currentTagName = $currentTagName;
$parseElementDebugId = $this->debugStart(
'Parse element ' . $this->currentTagName . ' (parent: ' . $this->parentTagName . ')'
);
$parserElements = $this->getAllowedChildElements($this->type, $this->currentTagName);
$dom = new HTML5DOMDocument();
$dom->loadHTML(
'<!DOCTYPE html><html lang=""><head><meta charset="UTF-8"></head><body><' . $this->tempTagName . '>' .
$this->getSafeHtml($string) .
'</' . $this->tempTagName . '></body></html>',
LIBXML_NONET | HTML5DOMDocument::ALLOW_DUPLICATE_IDS
);
$xPath = new DOMXPath($dom);
$filterReplacements = '//' . implode('|//', $parserElements);
$unfilteredDomNodes = $xPath->query($filterReplacements);
$domNodes = [];
/**
* @var HTML5DOMElement $unfilteredDomNode
*/
foreach ($unfilteredDomNodes as $unfilteredDomNode) {
$nodePath = $unfilteredDomNode->getNodePath();
$count = substr_count($nodePath, '/' . $this->name);
if ($count === 1) {
$domNodes[] = $unfilteredDomNode;
}
}
$domNodeCount = count($domNodes);
$getParsedContent = false;
if ($domNodeCount === 0) {
$result = $string;
} else {
$result = [];
$oldDeep = $this->currentDeep;
$this->currentDeep++;
if ($this->currentDeep > $this->maxDeep) {
$this->getLogger()->emergency('Max deep of ' . $this->maxDeep . ' reached', [
'time' => microtime(true),
'memory' => memory_get_usage()
]);
}
foreach ($domNodes as $domNode) {
$tagName = $domNode->tagName;
if (isset($this->parserElements[$tagName])) {
$tagName = $this->parserElements[$tagName]->tagName();
if (!isset($this->nodeStats[$tagName]['usage'])) {
$this->nodeStats[$tagName]['usage'] = 0;
}
$this->nodeStats[$tagName]['usage']++;
$this->nodeStats[$tagName]['number'] = ($this->nodeStats[$tagName]['number'] ?? 0) + 1;
$this->prepareNode($xPath, $domNode, $this->parserElements[$tagName], $tagName);
$childCurrentTagName = $this->currentTagName;
$childParentTagName = $this->parentTagName;
$this->parentTagName = $this->currentTagName;
$this->currentTagName = $tagName;
try {
$replacement = $this->parserElements[$tagName]->run();
} catch (ErrorException $exception) {
throw new RuntimeException(
sprintf(
'Cannot run element "%s".
Line: %s
Code: %s
Error: %s',
$currentTagName,
(int)$domNode->getLineNo(),
$domNode->outerHTML,
$exception->getMessage()
)
);
}
$this->parentTagName = $childParentTagName;
$this->currentTagName = $childCurrentTagName;
if ($rawData && $domNodeCount > 1) {
$result[] = $replacement;
} elseif ($rawData && (is_array($replacement) || is_object($replacement))) {
$result = $replacement;
} else {
if ($replacement !== null) {
if (!is_string($replacement)) {
$replacement = $this->getSafeValue($replacement);
}
$replacement = $this->getSafeHtml($replacement ?? '');
}
$domNode->outerHTML = $replacement ?? '';
$getParsedContent = true;
}
}
}
$this->currentDeep = $oldDeep;
if ($getParsedContent) {
/**
* @var HTML5DOMElement[] $node
*/
$node = $xPath->query('//' . $this->tempTagName);
$result = $node[0]->innerHTML;
}
}
$this->currentLanguage = $parentLanguage;
$this->currentTagName = $this->parentTagName;
$this->parentTagName = $parentTagName;
$this->debugEnd($parseElementDebugId);
return $result;
}
/**
* @param string $string
* @param bool $trimUtf8Nbsp
* @param bool $trimHtmlNbsp Only works when $trimUtf8Nbsp is true
*
* @return string
*/
public function fullTrim(string $string, bool $trimUtf8Nbsp = true, bool $trimHtmlNbsp = false): string
{
if ($trimUtf8Nbsp) {
if ($trimHtmlNbsp) {
$string = str_replace(' ', "\xC2\xA0", $string);
}
return trim(
$string,
" \t\n\r\0\x0B\xC2\xA0"
);
}
return trim($string);
}
protected array $specialTagMap = [];
/**
* @param string $string
*
* @return string
*/
protected function getSafeHtml(string $string): string
{
$string = preg_replace(
'/<!(?<tag>[^\->]+)>/mi',
'<' . $this->tempTagName . '-special>$1</' . $this->tempTagName . '-special>',
$string
);
return str_ireplace(
array_keys($this->specialTagMap),
array_values($this->specialTagMap),
$string
);
}
/**
* @param DOMXPath $xPath
* @param HTML5DOMElement $domNode
* @param ParserElement $parserElement
* @param string $currentTagName
*
* @throws Exception
*/
protected function prepareNode(
DOMXPath $xPath,
HTML5DOMElement $domNode,
ParserElement $parserElement,
string $currentTagName
): void {
$debugId = $this->debugStart('Prepare node ' . $currentTagName);
/**
* @var array<string, string> $attributes
*/
$attributes = [];
$data = [];
$hasChildren = false;
$contentSetting = null;
$content = null;
/**
* @var string|null $nodePath
*/
$nodePath = $domNode->getNodePath();
if (!empty($domNode->attributes)) {
/**
* @var DOMNode $attr
*/
foreach ($domNode->attributes as $attr) {
$attributes[$attr->nodeName] = $attr->nodeValue;
}
}
$elementSettings = $parserElement->elementSettings();
if ($elementSettings !== []) {
foreach ($elementSettings as $elementSetting) {
$settingName = $elementSetting->name;
$data[$settingName] = [];
//region elements
if ($elementSetting->element !== null) {
$childElementTagName = $this->parserElementsByClassName[$elementSetting->element]->tagName();
$childElementTagNames = array_merge(
[$childElementTagName],
$this->parserElementsByClassName[$elementSetting->element]->tagNameAliases()
);
$unfilteredChildNodes = $xPath->query('//' . implode('|//', $childElementTagNames));
$childNodes = [];
if ($unfilteredChildNodes !== false) {
/**
* @var HTML5DOMElement $unfilteredChildNode
*/
foreach ($unfilteredChildNodes as $unfilteredChildNode) {
/**
* @var string|null $childNodePath
*/
$childNodePath = $unfilteredChildNode->getNodePath();
if ($childNodePath === null) {
continue;
}
$childNodePathParts = explode('/', $childNodePath);
$currentChildNode = array_pop($childNodePathParts);
$bracketPosition = mb_strpos($currentChildNode, '[');
if ($bracketPosition !== false) {
$currentChildNode = mb_substr($currentChildNode, 0, $bracketPosition);
}
if (
implode('/', $childNodePathParts) === $nodePath &&
in_array($currentChildNode, $childElementTagNames, true)
) {
$childNodes[] = $unfilteredChildNode;
}
}
}
$childNodeCount = count($childNodes);
if ($childNodeCount > 0) {
$hasChildren = true;
if ($childNodeCount > 1 && !$elementSetting->multiple) {
throw new RuntimeException(
sprintf(
'Validation error of element "%s".
Element contains multiple "%s" child elements but only one is allowed!',
$parserElement->tagName(),
$childElementTagName
)
);
}
foreach ($childNodes as $childNode) {
$data[$settingName][] = &$this->getData(
$elementSetting,
$this->parseElements(
$childNode->outerHTML,
$currentTagName,
$elementSetting->rawData
)
);
}
}
}
//endregion elements
//region attribute
if ($elementSetting->multiple) {
$multipleAttributeExpression = strtr($elementSetting->multipleAttributeExpression, [
'{{name}}' => $settingName,
]);
$attributeNames = preg_grep($multipleAttributeExpression, array_keys($attributes));
} else {
$attributeNames = array_merge([$settingName], $elementSetting->getAliases());
}
foreach ($attributeNames as $attributeName) {
$attributeContent = $attributes[$attributeName] ?? null;
if ($attributeContent !== null) {
$attributeContent = &$this->getData($elementSetting, $attributeContent);
if ($elementSetting->attributeNameAsKey) {
$data[$settingName][$attributeName] = $attributeContent;
} else {
$data[$settingName][] = $attributeContent;
}
}
unset($attributeContent);
}
//endregion attribute
if (!$elementSetting->multiple) {
$data[$settingName] = $data[$settingName][array_key_first($data[$settingName])] ?? null;
}
if ($elementSetting->content) {
$contentSetting = $elementSetting;
$content = $data[$settingName];
}
}
}
if ($content === null && !$hasChildren) {
$content = $domNode->innerHTML;
if ($parserElement->isContentParsed()) {
if ($contentSetting !== null && $contentSetting->element !== null) {
$contentSettingName = $this->parserElementsByClassName[$contentSetting->element]->tagName();
$content = $this->parseElements(
'<' . $contentSettingName . '>' . $content . '</' . $contentSettingName . '>',
$currentTagName,
$contentSetting->rawData
);
} else {
$content = $this->parseElements($content, $currentTagName, $parserElement->isContentRawData());
}
}
if ($contentSetting !== null) {
$data[$contentSetting->name] = &$this->getData($contentSetting, $content);
$content = &$data[$contentSetting->name];
}
}
// Set data to make it possible to access other properties in validation
$parserElement->setData($data);
if ($elementSettings !== []) {
foreach ($elementSettings as $elementSetting) {
if (
$elementSetting->defaultValue !== null &&
(
$data[$elementSetting->name] === null ||
$data[$elementSetting->name] === [] ||
$data[$elementSetting->name] === ''
)
) {
$data[$elementSetting->name] = $elementSetting->defaultValue;
}
try {
$rules = new Rules($elementSetting->rules);
$result = $rules->validate($data[$elementSetting->name]);
if ($result->isValid() === false) {
throw new RuntimeException(
sprintf(
'Validation error of ElementSetting "%s" of element "%s".
Line: %s
Code: %s
Error: %s',
$elementSetting->name,
$currentTagName,
(int)$domNode->getLineNo(),
$domNode->outerHTML,
print_r($result->getErrors(), true)
)
);
}
} catch (Exception $exception) {
throw new RuntimeException(
sprintf(
'Cannot validate ElementSetting "%s" of element "%s".
Line: %s
Code: %s
Error: %s',
$elementSetting->name,
$currentTagName,
(int)$domNode->getLineNo(),
$domNode->outerHTML,
$exception->getMessage()
)
);
}
}
}
//Set data to update default values etc.
$parserElement->setData($data)
->setContent($content)
->setAttributes($attributes)
->setDomNode($domNode);
$this->debugEnd($debugId);
}
/**
* @param ElementSetting $elementSetting
* @param mixed $data
*
* @return mixed
*/
protected function &getData(ElementSetting $elementSetting, $data)
{
if (!$elementSetting->safeData) {
return $data;
}
return $this->getRawValue($data);
}
/**
* @param string $name
* @param null $data
* @param bool $callLastClosure
*
* @return mixed|null
*/
public function &getAttributeData(string $name, &$data = null, bool $callLastClosure = true)
{
if ($data === null) {
$data = &$this->data;
}
try {
return self::getValue($data, $name, $callLastClosure);
} catch (Exception $e) {
$this->getLogger()->warning($e->getMessage());
$value = null;
return $value;
}
}
/**
* @param string $name
* @param $value
* @param null $data
*/
public function setAttributeData(string $name, &$value, &$data = null): void
{
if ($data === null) {
$data = &$this->data;
}
self::setValue($data, $name, $value);
}
/**
* @param $data
* @param string $path
* @param $value
*/
public static function setValue(&$data, string $path, &$value): void
{
static::checkForClosure($data);
if ($path === '') {
$data = $value;
return;
}
$keys = explode('.', $path);
while (count($keys) > 1) {
$key = array_shift($keys);
if (!isset($data[$key])) {
$data[$key] = [];
}
static::checkForClosure($data[$key]);
$data = &$data[$key];
}
$data[array_shift($keys)] = &$value;
}
/**
* @param $data
* @param string $path
* @param bool $callLastClosure
*
* @return mixed|null
*/
public static function &getValue(&$data, string $path, bool $callLastClosure = true)
{
static::checkForClosure($data);
$workData = $data;
$value = null;
if (is_array($data)) {
$magicKeyValue = static::checkForMagicKey($data, $path);
if ($magicKeyValue !== null) {
return $magicKeyValue;
}
if (isset($data[$path])) {
static::checkForClosure($data[$path]);
return $data[$path];
}
}
if (($pos = strrpos($path, '.')) !== false) {
$mainKey = substr($path, 0, $pos);
$workData = static::getValue($data, $mainKey, $callLastClosure);
// TODO Removed to fix bug in loops. Need to check if it has a negative effect.
// $data[$mainKey] = $workData;
$path = substr($path, $pos + 1);
}
$magicKeyValue = static::checkForMagicKey($workData, $path);
if ($magicKeyValue !== null) {
return $magicKeyValue;
}
if (is_object($workData)) {
if (!$callLastClosure) {
if (method_exists($workData, $path)) {
$methodName = $path;
} elseif (method_exists($workData, 'get' . ucfirst($path))) {
$methodName = 'get' . ucfirst($path);
}
if (isset($methodName)) {
$value = Closure::fromCallable([$workData, $methodName]);
return $value;
}
}
$value = $workData->$path;
return $value;
}
if (isset($workData[$path])) {
if (is_array($workData)) {
$value = &$workData[$path];
} else {
$value = $workData[$path];
}
static::checkForClosure($value);
}
return $value;
}
/**
* @param $array
* @param string $key
*
* @return mixed|null
*/
protected static function checkForMagicKey($array, string $key)
{
if ($key === '$$count' && is_countable($array)) {
return count($array);
}
return null;
}
/**
* @param $value
*/
protected static function checkForClosure(&$value): void
{
if ($value instanceof Closure) {
$value = $value();
}
}
/**
* @param mixed $value
*
* @return mixed
*/
public function &getRawValue($value)
{
if (is_array($value) || is_object($value)) {
return $value;
}
if (is_string($value)) {
$value = $this->fullTrim($value);
}
if (is_numeric($value)) {
/**
* @var int|float $value
*/
if (is_int($value) || mb_strpos((string)$value, '.') === false) {
$value = (int)$value;
return $value;
}
$value = (float)$value;
return $value;
}
if ($value === 'true' || $value === 't') {
$value = true;
return $value;
}
if ($value === 'false' || $value === 'f') {
$value = false;
return $value;
}
if (
(mb_strpos($value, '\'') === 0 && mb_strrpos($value, '\'') === mb_strlen($value) - 1) ||
(mb_strpos($value, '"') === 0 && mb_strrpos($value, '"') === mb_strlen($value) - 1)
) {
$value = mb_substr($value, 1, -1);
return $value;
}
if (mb_strpos($value, '.') === 0) {
/** @noinspection PhpUnnecessaryLocalVariableInspection */
$dataValue = &$this->getAttributeData(mb_substr($value, 1));
return $dataValue;
}
return $value;
}
/**
* @param mixed $value
*
* @return string
*/
public function getSafeValue($value): string
{
if (is_numeric($value)) {
return (string)$value;
}
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if (is_string($value)) {
return '\'' . $value . '\'';
}
if (!isset($this->data['temp-data-storage']) || !is_array($this->data['temp-data-storage'])) {
$this->data['temp-data-storage'] = [];
}
/**
* @phpstan-ignore-next-line
*/
$count = count($this->data['temp-data-storage']);
$tempName = 'temp-data-storage.' . $count;
$this->setAttributeData($tempName, $value);
/**
* @noinspection UselessUnsetInspection
*/
unset($value);
$value = '.' . $tempName;
return $value;
}
//region debug
/**
* @var array<string, array{message: string, memory: int, time: float}>
*/
protected array $debugData = [];
/**
* @param string $message
*
* @return string|null
*/
protected function debugStart(string $message): ?string
{
if ($this->mode === self::MODE_DEV) {
$debugId = md5(uniqid((string)mt_rand(), true));
$this->debugData[$debugId] = [
'message' => $message,
'memory' => memory_get_usage(),
'time' => microtime(true)
];
$this->debug(str_repeat('│ ', count($this->debugData) - 1) . '┌─ ' . $message);
return $debugId;
}
return null;
}
/**
* @param string|null $debugId
*/
protected function debugEnd(?string $debugId): void
{
if ($this->mode === self::MODE_DEV && $debugId !== null) {
$debugData = $this->debugData[$debugId];
$currentMemory = memory_get_usage();
$memory = $currentMemory - $debugData['memory'];
$currentTime = microtime(true);
unset($this->debugData[$debugId]);
$this->debug(str_repeat('│ ', count($this->debugData)) . '└─ ' . $debugData['message'], [
'memory' => $this->getHumanSize($memory),
'time' => $this->getHumanTime($debugData['time'], $currentTime),
]);
}
}
/**
* @param float $start
* @param float $end
*
* @return string
*/
protected function getHumanTime(float $start, float $end): string
{
$microseconds = $end - $start;
$minutes = (int)($seconds = (int)($milliseconds = ($microseconds * 10000)) / 10000) / 60;
return (($minutes % 60) > 0 ? ($minutes % 60) . 'min ' : '') .
(($seconds % 60) > 0 ? ($seconds % 60) . 'sec ' : '') .
($milliseconds > 0 ? (($milliseconds % 10000) / 10) . 'ms' : '');
}
/**
* @param int $size
*
* @return string
*/
protected function getHumanSize(int $size): string
{
$units = ['b','kb','mb','gb','tb','pb'];
if ($size === 0) {
return '0 b';
}
return round($size / (1024 ** ($unitIndex = floor(log($size, 1024)))), 2) . ' ' . $units[(int)$unitIndex];
}
/**
* @param string $message
* @param array<string, string> $extras
*/
protected function debug(string $message, array $extras = []): void
{
if ($this->mode === self::MODE_DEV) {
foreach ($extras as $name => $value) {
$message .= ' | ' . $name . ': ' . $value;
}
$this->getLogger()->debug($message);
}
}
//endregion debug
}