romanzaycev/tooolooop

View on GitHub
src/Romanzaycev/Tooolooop/Template/Template.php

Summary

Maintainability
A
3 hrs
Test Coverage
<?php declare(strict_types=1);

/**
 * This file is part of the Tooolooop.
 * Copyright (c) 2017-2019 Roman Zaycev <box@romanzaycev.ru>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Romanzaycev\Tooolooop\Template;

use Romanzaycev\Tooolooop\EngineInterface;
use Romanzaycev\Tooolooop\Scope\ScopeInterface;
use Romanzaycev\Tooolooop\Template\Exceptions\NestedBlockRenderingException;
use Romanzaycev\Tooolooop\Template\Exceptions\NoStartingBlockException;
use Romanzaycev\Tooolooop\Template\Exceptions\RestrictedBlockName;
use Romanzaycev\Tooolooop\Template\Exceptions\TemplateNotFoundException;

/**
 * Class Template
 *
 * @author Roman Zaycev <box@romanzaycev.ru>
 * @package Romanzaycev\Tooolooop\Template
 */
class Template implements TemplateInterface
{

    const CONTENT_NAME = 'content';

    /**
     * @var EngineInterface
     */
    private $engine;

    /**
     * @var string
     */
    private $name;

    /**
     * @var string
     */
    private $parentTemplate;

    /**
     * @var array
     */
    private $parentTemplateData = [];

    /**
     * @var array
     */
    private $data = [];

    /**
     * @var null|string
     */
    private $currentBlock = null;

    /**
     * @var array
     */
    private $blocks = [];

    /**
     * @var array
     */
    protected $inheritedBlocks = [];

    /**
     * Template constructor.
     *
     * @param EngineInterface $engine template engine
     * @param string $name template name
     */
    public function __construct(EngineInterface $engine, string $name)
    {
        $this->engine = $engine;
        $this->name = $name;
    }

    /**
     * Assign template data.
     *
     * @param array $data template data
     * @return TemplateInterface self template
     */
    public function assign(array $data = []): TemplateInterface
    {
        $_data = [];

        foreach (\array_keys($data) as $key) {
            if (\is_string($key)) {
                $_data[$key] = $data[$key];
            }
        }

        $this->data = \array_merge($this->data, $_data);

        return $this;
    }

    /**
     * Render template.
     *
     * @param array $data template data
     * @param ScopeInterface|null $scope optional template rendering scope
     * @return string rendered template
     * @throws TemplateNotFoundException
     * @throws \Throwable
     */
    public function render(array $data = [], ScopeInterface $scope = null): string
    {
        $this->assign($data);

        if (!$this->checkPath()) {
            throw new TemplateNotFoundException(sprintf("Template \"%s\" not found", $this->name));
        }

        $bufferingLevel = 0;

        try {
            $bufferingLevel = \ob_get_level();
            \ob_start();

            if (\is_null($scope)) {
                $scope = $this->engine->getScope();
            }

            $scope->setTemplate($this);
            $scope->setData($this->data);
            $scope->perform($this->getPath());
            $content = \ob_get_clean();

            return $this->renderParentTemplate($content);
        } catch (\Throwable $e) {
            while (\ob_get_level() > $bufferingLevel) {
                \ob_end_clean();
            }

            throw $e;
        }
    }

    /**
     * Set parent template name.
     *
     * @param string $template parent template name
     * @param array $data parent template data
     */
    protected function extend(string $template, array $data = [])
    {
        if ($this->isSiblingTemplate($template)) {
            $this->parentTemplate = $this->resolveSiblingTemplatePath($template);
        } else {
            $this->parentTemplate = $template;
        }

        $this->parentTemplateData = $data;
    }

    /**
     * Include other template as chunk.
     *
     * @param string $template child template name
     * @param array $data additional child template data
     * @return string
     */
    protected function load(string $template, $data = []): string
    {
        if ($this->isSiblingTemplate($template)) {
            $template = $this->resolveSiblingTemplatePath($template);
        }

        return $this
            ->engine
            ->make($template)
            ->assign($this->data)
            ->render($data);
    }

    /**
     * Show child template block.
     *
     * @param string $block
     * @return string
     */
    protected function block(string $block = ''): string
    {
        if ($block === '') {
            $block = self::CONTENT_NAME;
        }

        if (\array_key_exists($block, $this->inheritedBlocks)) {
            return $this->inheritedBlocks[$block];
        }

        return '';
    }

    /**
     * Set starting block marker.
     *
     * @param string $name
     * @throws NestedBlockRenderingException
     * @throws RestrictedBlockName
     */
    protected function start(string $name)
    {
        if (empty($name)) {
            throw new \InvalidArgumentException(sprintf(
                "Empty block name in template \"%s\"",
                $this->name
            ));
        }

        if (!\is_null($this->currentBlock)) {
            throw new NestedBlockRenderingException(
                \sprintf(
                    "Nested block rendering is prohibited. Trying to render block \"%s\" in template \"%s\"",
                    $name,
                    $this->name . '.' . $this->engine->getExtension()
                )
            );
        }

        if (\strtolower($name) === self::CONTENT_NAME) {
            throw new RestrictedBlockName(\sprintf(
                "Block name `content` in restricted for use. Template \"%s\"",
                $this->name
            ));
        }

        $this->currentBlock = $name;
        $this->blocks[$name] = '';

        \ob_start();
    }

    /**
     * Set ending block marker.
     *
     * @throws \Exception
     * @throws \Throwable
     */
    protected function end()
    {
        if (\is_null($this->currentBlock)) {
            throw new NoStartingBlockException(
                \sprintf("Unexpected block ending, template \"%s\"", $this->name)
            );
        }

        $content = \ob_get_clean();
        $this->blocks[$this->currentBlock] = $content;
        $this->currentBlock = null;
    }

    /**
     * Apply filters and return escaped variable.
     *
     * @param mixed $variable
     * @param array $filters
     * @return mixed
     */
    protected function e($variable, $filters = [])
    {
        if (!\in_array('escape', $filters)) {
            $filters[] = 'escape';
        }

        return $this->applyFunctions(
            $variable,
            $filters
        );
    }

    /**
     * @param string $content
     * @return string
     * @throws TemplateNotFoundException
     * @throws \Throwable
     */
    private function renderParentTemplate(string $content = ''): string
    {
        if (!\is_null($this->parentTemplate)) {
            /**
             * @var Template $parentTemplate
             */
            $parentTemplate = $this->engine->make(
                $this->parentTemplate
            );

            $parentTemplate->inheritedBlocks = \array_merge(
                $this->blocks,
                [self::CONTENT_NAME => $content]
            );

            $content = $parentTemplate->render(\array_merge(
                $this->data,
                $this->parentTemplateData
            ));
        }

        return $content;
    }

    /**
     * @param string $template
     * @return bool
     */
    private function isSiblingTemplate(string $template): bool
    {
        if (\substr($template, 0, 1) === '.') {
            $directory = \dirname($this->getPath());
            $template = $this->sanitizePathSegment($template);
            $extension = $this->engine->getExtension();

            return (
                \file_exists($directory . DIRECTORY_SEPARATOR . $template . '.' . $extension)
                && !\is_dir($directory . DIRECTORY_SEPARATOR . $template . '.' . $extension)
            );
        }

        return false;
    }

    /**
     * @param string $template
     * @return string
     */
    private function resolveSiblingTemplatePath(string $template): string
    {
        $template = $this->sanitizePathSegment($template);
        $currentTemplate = $this->sanitizePathSegment($this->name);
        $segments = \explode(DIRECTORY_SEPARATOR, $currentTemplate);
        $length = \count($segments);

        if ($length === 1) {
            return $template;
        }

        $segments[$length - 1] = $template;

        return \implode(DIRECTORY_SEPARATOR, $segments);
    }

    /**
     * @return bool
     */
    private function checkPath(): bool
    {
        $path = $this->getPath();

        return (\file_exists($path) && !\is_dir($path));
    }

    /**
     * @return string
     */
    private function getPath(): string
    {
        $name = $this->sanitizePathSegment($this->name);
        $ds = DIRECTORY_SEPARATOR;

        return \sprintf(
            '%s%s%s%s.%s',
            DIRECTORY_SEPARATOR,
            \trim($this->engine->getDirectory(), $ds),
            $ds,
            $name,
            $this->engine->getExtension()
        );
    }

    /**
     * @param string $pathSegment
     * @return string
     */
    private function sanitizePathSegment($pathSegment): string
    {
        $ds = DIRECTORY_SEPARATOR;
        $pathSegment = \str_replace('\\', $ds, $pathSegment);

        $segments = \explode($ds, \trim($pathSegment, $ds));

        $sanitizedSegments = [];
        foreach ($segments as $segment) {
            $segment = \trim(\basename($segment), '.');

            if ($segment) {
                $sanitizedSegments[] = $segment;
            }
        }

        return \implode($ds, $sanitizedSegments);
    }

    /**
     * @param mixed $value
     * @param array $functions
     * @return mixed
     */
    private function applyFunctions($value, array $functions = [])
    {
        $appliedValue = $value;

        foreach ($functions as $k => $v) {
            $params = [];
            if (\is_numeric($k)) {
                $filter = $v;
            } else {
                $filter = $k;
                $params = $v;
            }

            $arguments = $this->getFunctionArguments($appliedValue, $params);

            if (!\is_null($appliedValue)) {
                if (\is_callable($filter)) {
                    $appliedValue = \call_user_func_array($filter, $arguments);
                } else {
                    $appliedValue = \call_user_func_array(
                        $this->engine->getFilterFunction($filter),
                        $arguments
                    );
                }
            }
        }

        return $appliedValue;
    }

    /**
     * @param mixed $params
     * @return array
     */
    private function getFunctionParams($params): array
    {
        if (!\is_array($params)) {
            $params = [$params];
        }

        return $params;
    }

    /**
     * @param mixed $value
     * @param mixed $params
     * @return array
     */
    private function getFunctionArguments($value, $params)
    {
        return \array_merge(
            [$value],
            $this->getFunctionParams($params)
        );
    }
}