src/HtmlTemplate.php

Summary

Maintainability
A
35 mins
Test Coverage
<?php

declare(strict_types=1);

namespace Atk4\Ui;

use Atk4\Core\WarnDynamicPropertyTrait;
use Atk4\Ui\HtmlTemplate\TagTree;
use Atk4\Ui\HtmlTemplate\Value as HtmlValue;

/**
 * @phpstan-consistent-constructor
 */
class HtmlTemplate
{
    use WarnDynamicPropertyTrait;

    public const TOP_TAG = '_top';

    /** @var array<string, string|false> */
    private static array $_realpathCache = [];
    /** @var array<string, string|false> */
    private static array $_filesCache = [];

    private static ?self $_parseCacheParentTemplate = null;
    /** @var array<string, array<string, TagTree>> */
    private static array $_parseCache = [];

    /** @var array<string, TagTree> */
    private array $tagTrees;

    public function __construct(string $template = '')
    {
        $this->loadFromString($template);
    }

    protected function _hasTag(string $tag): bool
    {
        return isset($this->tagTrees[$tag]);
    }

    /**
     * @param string|list<string> $tag
     */
    public function hasTag($tag): bool
    {
        // check if all tags exist
        if (is_array($tag)) {
            foreach ($tag as $t) {
                if (!$this->_hasTag($t)) {
                    return false;
                }
            }

            return true;
        }

        return $this->_hasTag($tag);
    }

    public function getTagTree(string $tag): TagTree
    {
        if (!isset($this->tagTrees[$tag])) {
            throw (new Exception('Tag is not defined in template'))
                ->addMoreInfo('tag', $tag)
                ->addMoreInfo('template_tags', array_diff(array_keys($this->tagTrees), [self::TOP_TAG]));
        }

        return $this->tagTrees[$tag];
    }

    /**
     * @param array<string, TagTree> $tagTrees
     *
     * @return array<string, TagTree>
     */
    private function cloneTagTrees(array $tagTrees): array
    {
        $res = [];
        foreach ($tagTrees as $k => $v) {
            $res[$k] = $v->clone($this);
        }

        return $res;
    }

    public function __clone()
    {
        $this->tagTrees = $this->cloneTagTrees($this->tagTrees);
    }

    /**
     * @return static
     */
    public function cloneRegion(string $tag): self
    {
        $template = new static();
        $template->tagTrees = $template->cloneTagTrees($this->tagTrees);

        // rename top tag tree
        $topTagTree = $template->tagTrees[$tag];
        unset($template->tagTrees[$tag]);
        $template->tagTrees[self::TOP_TAG] = $topTagTree;
        $topTag = self::TOP_TAG;
        \Closure::bind(static function () use ($topTagTree, $topTag) {
            $topTagTree->tag = $topTag;
        }, null, TagTree::class)();

        // TODO prune unreachable nodes
        // $template->rebuildTagsIndex();

        return $template;
    }

    protected function _unsetFromTagTree(TagTree $tagTree, int $k): void
    {
        \Closure::bind(static function () use ($tagTree, $k) {
            if ($k === array_key_last($tagTree->children)) {
                array_pop($tagTree->children);
            } else {
                unset($tagTree->children[$k]);
            }
        }, null, TagTree::class)();
    }

    protected function emptyTagTree(TagTree $tagTree): void
    {
        foreach ($tagTree->getChildren() as $k => $v) {
            if ($v instanceof TagTree) {
                $this->emptyTagTree($v);
            } else {
                $this->_unsetFromTagTree($tagTree, $k);
            }
        }
    }

    /**
     * Internal method for setting or appending content in $tag.
     *
     * If tag contains another tag trees, these tag trees are emptied.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     */
    protected function _setOrAppend($tag, ?string $value = null, bool $encodeHtml = true, bool $append = false, bool $throwIfNotFound = true): void
    {
        // $tag passed as associative array [tag => value]
        if (is_array($tag) && $value === null) { // @phpstan-ignore identical.alwaysFalse, booleanAnd.alwaysFalse
            if ($throwIfNotFound) {
                foreach ($tag as $k => $v) {
                    if (!$this->_hasTag($k)) {
                        $this->_setOrAppend($k, $v, $encodeHtml, $append, true);
                    }
                }
            }

            foreach ($tag as $k => $v) {
                $this->_setOrAppend($k, $v, $encodeHtml, $append, $throwIfNotFound);
            }

            return;
        }

        if (!is_string($tag) || $tag === '') {
            throw (new Exception('Tag must be ' . (is_string($tag) ? 'non-empty ' : '') . 'string'))
                ->addMoreInfo('tag', $tag)
                ->addMoreInfo('value', $value);
        }

        if ($value === null) {
            $value = '';
        }

        $htmlValue = new HtmlValue();
        if ($encodeHtml) {
            $htmlValue->set($value);
        } else {
            $htmlValue->dangerouslySetHtml($value);
        }

        // set or append value
        if (!$throwIfNotFound && !$this->hasTag($tag)) {
            return;
        }

        $tagTree = $this->getTagTree($tag);
        if (!$append) {
            $this->emptyTagTree($tagTree);
        }
        $tagTree->add($htmlValue);
    }

    /**
     * This function will replace region referred by $tag to a new content.
     *
     * If tag is found inside template several times, all occurrences are
     * replaced.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function set($tag, ?string $value = null): self
    {
        $this->_setOrAppend($tag, $value, true, false);

        return $this;
    }

    /**
     * Same as set(), but won't generate exception for non-existing
     * $tag.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function trySet($tag, ?string $value = null): self
    {
        $this->_setOrAppend($tag, $value, true, false, false);

        return $this;
    }

    /**
     * Set value of a tag to a HTML content. The value is set without
     * encoding, so you must be sure to sanitize.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function dangerouslySetHtml($tag, ?string $value = null): self
    {
        $this->_setOrAppend($tag, $value, false, false);

        return $this;
    }

    /**
     * See dangerouslySetHtml() but won't generate exception for non-existing
     * $tag.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function tryDangerouslySetHtml($tag, ?string $value = null): self
    {
        $this->_setOrAppend($tag, $value, false, false, false);

        return $this;
    }

    /**
     * Add more content inside a tag.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function append($tag, ?string $value): self
    {
        $this->_setOrAppend($tag, $value, true, true);

        return $this;
    }

    /**
     * Same as append(), but won't generate exception for non-existing
     * $tag.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function tryAppend($tag, ?string $value): self
    {
        $this->_setOrAppend($tag, $value, true, true, false);

        return $this;
    }

    /**
     * Add more content inside a tag. The content is appended without
     * encoding, so you must be sure to sanitize.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function dangerouslyAppendHtml($tag, ?string $value): self
    {
        $this->_setOrAppend($tag, $value, false, true);

        return $this;
    }

    /**
     * Same as dangerouslyAppendHtml(), but won't generate exception for non-existing
     * $tag.
     *
     * @param string|array<string, string>          $tag
     * @param ($tag is array ? never : string|null) $value
     *
     * @return $this
     */
    public function tryDangerouslyAppendHtml($tag, ?string $value): self
    {
        $this->_setOrAppend($tag, $value, false, true, false);

        return $this;
    }

    /**
     * Empty contents of specified region. If region contains sub-hierarchy,
     * it will be also removed.
     *
     * @param string|list<string> $tag
     *
     * @return $this
     */
    public function del($tag): self
    {
        if (is_array($tag)) {
            foreach ($tag as $t) {
                $this->del($t);
            }

            return $this;
        }

        $tagTree = $this->getTagTree($tag);
        \Closure::bind(static function () use ($tagTree) {
            $tagTree->children = [];
        }, null, TagTree::class)();

        // TODO prune unreachable nodes
        // $template->rebuildTagsIndex();

        return $this;
    }

    /**
     * Similar to del() but won't throw exception if tag is not present.
     *
     * @param string|list<string> $tag
     *
     * @return $this
     */
    public function tryDel($tag): self
    {
        if (is_array($tag)) {
            foreach ($tag as $t) {
                $this->tryDel($t);
            }

            return $this;
        }

        if ($this->hasTag($tag)) {
            $this->del($tag);
        }

        return $this;
    }

    /**
     * @return $this
     */
    public function loadFromFile(string $filename): self
    {
        if ($this->tryLoadFromFile($filename) !== false) {
            return $this;
        }

        throw (new Exception('Unable to read template from file'))
            ->addMoreInfo('filename', $filename);
    }

    /**
     * Same as load(), but will not throw an exception.
     *
     * @return $this|false
     */
    public function tryLoadFromFile(string $filename)
    {
        // realpath() is slow on Windows, so cache it and dedup only directories
        $filenameBase = basename($filename);
        $filename = dirname($filename);
        if (!isset(self::$_realpathCache[$filename])) {
            self::$_realpathCache[$filename] = realpath($filename);
        }
        $filename = self::$_realpathCache[$filename];
        if ($filename === false) {
            return false;
        }
        $filename .= '/' . $filenameBase;

        if (!isset(self::$_filesCache[$filename])) {
            $data = @file_get_contents($filename);
            if ($data !== false) {
                $data = preg_replace('~(?:\r\n?|\n)$~sD', '', $data); // always trim end NL
            }
            self::$_filesCache[$filename] = $data;
        }

        $str = self::$_filesCache[$filename];
        if ($str === false) {
            return false;
        }

        $this->loadFromString($str, true);

        return $this;
    }

    /**
     * @return $this
     */
    public function loadFromString(string $str, bool $allowParseCache = false): self
    {
        $this->parseTemplate($str, $allowParseCache);

        return $this;
    }

    /**
     * @param list<string> $inputReversed
     */
    protected function parseTemplateTree(array &$inputReversed, ?string $openedTag = null): TagTree
    {
        $tagTree = new TagTree($this, $openedTag ?? self::TOP_TAG);

        $chunk = array_pop($inputReversed);
        if ($chunk !== '') {
            $tagTree->add((new HtmlValue())->dangerouslySetHtml($chunk));
        }

        while (($tag = array_pop($inputReversed)) !== null) {
            $firstChar = substr($tag, 0, 1);
            if ($firstChar === '/') { // is closing tag
                $tag = substr($tag, 1);
                if ($openedTag === null
                    || ($tag !== '' && $tag !== $openedTag)) {
                    throw (new Exception('Template parse error: tag was not opened'))
                        ->addMoreInfo('opened_tag', $openedTag)
                        ->addMoreInfo('tag', $tag);
                }

                $openedTag = null;

                break;
            }

            // is new/opening tag
            $childTagTree = $this->parseTemplateTree($inputReversed, $tag);
            $this->tagTrees[$tag] = $childTagTree;
            $tagTree->addTag($tag);

            $chunk = array_pop($inputReversed);
            if ($chunk !== null && $chunk !== '') {
                $tagTree->add((new HtmlValue())->dangerouslySetHtml($chunk));
            }
        }

        if ($openedTag !== null) {
            throw (new Exception('Template parse error: tag is not closed'))
                ->addMoreInfo('tag', $openedTag);
        }

        return $tagTree;
    }

    protected function parseTemplate(string $str, bool $allowParseCache): void
    {
        $cKey = static::class . "\0" . $str;
        if (!isset(self::$_parseCache[$cKey])) {
            // expand self-closing tags {$tag} -> {tag}{/tag}
            $str = preg_replace('~\{\$([\w\-:]+)\}~', '{\1}{/\1}', $str);

            $input = preg_split('~\{(/?[\w\-:]*)\}~', $str, -1, \PREG_SPLIT_DELIM_CAPTURE);
            $inputReversed = array_reverse($input); // reverse to allow to use fast array_pop()

            $this->tagTrees = [];
            try {
                $this->tagTrees[self::TOP_TAG] = $this->parseTemplateTree($inputReversed);
                $tagTrees = $this->tagTrees;
            } finally {
                $this->tagTrees = [];
            }

            if (!$allowParseCache) {
                $this->tagTrees = $tagTrees;

                return;
            }

            if (self::$_parseCacheParentTemplate === null) {
                $cKeySelfEmpty = self::class . "\0";
                self::$_parseCache[$cKeySelfEmpty] = [];
                try {
                    self::$_parseCacheParentTemplate = new self();
                } finally {
                    unset(self::$_parseCache[$cKeySelfEmpty]);
                }
            }
            $parentTemplate = self::$_parseCacheParentTemplate;

            \Closure::bind(static function () use ($tagTrees, $parentTemplate) {
                foreach ($tagTrees as $tagTree) {
                    $tagTree->parentTemplate = $parentTemplate;
                }
            }, null, TagTree::class)();
            self::$_parseCache[$cKey] = $tagTrees;
        }

        $this->tagTrees = $this->cloneTagTrees(self::$_parseCache[$cKey]);
    }

    public function toLoadableString(string $region = self::TOP_TAG): string
    {
        $res = [];
        foreach ($this->getTagTree($region)->getChildren() as $v) {
            if ($v instanceof HtmlValue) {
                $res[] = $v->getHtml();
            } elseif ($v instanceof TagTree) {
                $tag = $v->getTag();
                $tagInnerStr = $this->toLoadableString($tag);
                $res[] = $tagInnerStr === ''
                    ? '{$' . $tag . '}'
                    : '{' . $tag . '}' . $tagInnerStr . '{/' . $tag . '}';
            } else {
                throw (new Exception('Value class has no save support'))
                    ->addMoreInfo('value_class', get_class($v));
            }
        }

        return implode('', $res);
    }

    public function renderToHtml(?string $region = null): string
    {
        return $this->renderTagTreeToHtml($this->getTagTree($region ?? self::TOP_TAG));
    }

    protected function renderTagTreeToHtml(TagTree $tagTree): string
    {
        $res = [];
        foreach ($tagTree->getChildren() as $v) {
            if ($v instanceof HtmlValue) {
                $res[] = $v->getHtml();
            } elseif ($v instanceof TagTree) {
                $res[] = $this->renderTagTreeToHtml($v);
            } elseif ($v instanceof self) {
                $res[] = $v->renderToHtml();
            } else {
                throw (new Exception('Unexpected value class'))
                    ->addMoreInfo('value_class', get_class($v));
            }
        }

        return implode('', $res);
    }
}