Making Zend view helpers usable with Plates engine

@matthew

For my my project, I needed use the zend view helpers, including navigation helpers… with the Plates template engine which I use with Zend Expressive implementation. As I don’t like much the Zend View component. My first idea was to provide a Plates extension which would consume the view helper manager but after thinking a bit more, I’ve ended with the solution to override the default Plates engine, making it able to compose the zend view helper manager. Of course, some view helpers are not usable by their nature (they are too close to the zend view renderer or irrelevant with the Plates engine), but most of them are working as expected, except in async context as the helpers are not stateless, through… that’s another problem.

My current working code is as follows:

Plates engine custom implementation which compose a \Zend\View\HelperPluginManager\HelperPluginManager

<?php

declare(strict_types=1);

namespace iMSCP\Layout\Plates;

use League\Plates\Engine as PlatesEngine;
use League\Plates\Template\Func;
use Zend\ServiceManager\ServiceManager;
use Zend\View\Helper\AbstractHelper;
use Zend\View\HelperPluginManager;
use Zend\View\Renderer\RendererInterface;
use Zend\View\Resolver\ResolverInterface;

class Engine extends PlatesEngine implements RendererInterface
{
    /**
     * Helper plugin manager
     *
     * @var HelperPluginManager
     */
    private $helpers;

    /**
     * @inheritdoc
     */
    public function getEngine()
    {
        return $this;
    }

    /**
     * @inheritdoc
     */
    public function setResolver(ResolverInterface $resolver)
    {
        return $this;
    }

    /**
     * Set helper plugin manager instance
     *
     * @param  string|HelperPluginManager $helpers
     * @return RendererInterface
     * @throws \InvalidArgumentException
     */
    public function setHelperPluginManager($helpers)
    {
        if (is_string($helpers)) {
            if (!class_exists($helpers)) {
                throw new \InvalidArgumentException(sprintf(
                    'Invalid helper helpers class provided (%s)',
                    $helpers
                ));
            }

            $helpers = new $helpers(new ServiceManager());
        }

        if (!$helpers instanceof HelperPluginManager) {
            throw new \InvalidArgumentException(sprintf(
                'Helper helpers must extend Zend\View\HelperPluginManager; got type "%s" instead',
                (is_object($helpers) ? get_class($helpers) : gettype($helpers))
            ));
        }

        $helpers->setRenderer($this);
        $this->helpers = $helpers;

        return $this;
    }

    /**
     * Get helper plugin manager instance
     *
     * @return HelperPluginManager
     */
    public function getHelperPluginManager()
    {
        if (NULL === $this->helpers) {
            $this->setHelperPluginManager(new HelperPluginManager(new ServiceManager()));
        }

        return $this->helpers;
    }

    /**
     * Get a template function (or view helper).
     *
     * @param  string $name
     * @return Func
     */
    public function getFunction($name)
    {
        try {
            return parent::getFunction($name);
        } catch (\LogicException $e) {
            $name = strtolower($name);
            if(!$this->doesFunctionExist($name)) {
                $plugin = $this->getHelperPluginManager()->get($name);
                $this->registerFunction($name, is_callable($plugin) ? $plugin : [$plugin, $name]);
            }
        }

        return parent::getFunction($name);
    }

    /**
     * Get plugin instance
     *
     * @param  string $name Name of plugin to return
     * @param  null|array $options Options to pass to plugin constructor (if not already instantiated)
     * @return AbstractHelper
     */
    public function plugin($name, array $options = NULL)
    {
        return $this->getHelperPluginManager()->get($name, $options);
    }

    /**
     * Overloading: proxy to view helpers
     *
     * Proxies to the attached plugin manager to retrieve, return, and potentially
     * execute helpers.
     *
     * * If the helper does not define __invoke, it will be returned
     * * If the helper does define __invoke, it will be called as a functor
     *
     * @param  string $method
     * @param  array $argv
     * @return mixed
     */
    public function __call($method, $argv)
    {
        $plugin = $this->plugin($method);

        if (is_callable($plugin)) {
            return call_user_func_array($plugin, $argv);
        }

        return $plugin;
    }

    /**
     * @inheritdoc
     * @throws \Throwable
     */
    public function render($name, $data = array())
    {
        return $this->make($name)->render((array)$data);
    }
}

Plates custom engine factory

<?php

declare(strict_types=1);

namespace iMSCP\Layout\Plates;

use League\Plates\Extension\ExtensionInterface;
use Psr\Container\ContainerInterface;
use Zend\Expressive\Helper;
use Zend\Expressive\Plates\Exception;
use Zend\Expressive\Plates\Extension;
use Zend\View\HelperPluginManager;
use function class_exists;
use function get_class;
use function gettype;
use function is_array;
use function is_object;
use function is_string;
use function sprintf;

/**
 * Create and return a Plates engine instance.
 *
 */
class PlatesEngineFactory
{
    public function __invoke(ContainerInterface $container): Engine
    {
        $config = $container->has('config') ? $container->get('config') : [];
        $config = isset($config['plates']) ? $config['plates'] : [];

        // Create the engine instance:
        $engine = new Engine();

        $this->injectViewHelperManager($container, $engine);
        $this->injectUrlExtension($container, $engine);
        $this->injectEscaperExtension($container, $engine);

        if (isset($config['extensions']) && is_array($config['extensions'])) {
            $this->injectExtensions($container, $engine, $config['extensions']);
        }

        return $engine;
    }

    /**
     * Inject the ViewHelperManager.
     */
    private function injectViewHelperManager(ContainerInterface $container, Engine $engine): void
    {
        if (!$container->has(HelperPluginManager::class)) {
            return;
        }

        $engine->setHelperPluginManager($container->get(HelperPluginManager::class));
    }

    /**
     * Inject the URL/ServerUrl extensions.
     *
     * If a service by the name of the UrlExtension class exists, fetches
     * and loads it.
     *
     * Otherwise, instantiates the UrlExtensionFactory, and invokes it with
     * the container, loading the result into the engine.
     */
    private function injectUrlExtension(ContainerInterface $container, Engine $engine): void
    {
        if ($container->has(Extension\UrlExtension::class)) {
            $engine->loadExtension($container->get(Extension\UrlExtension::class));
            return;
        }

        // If the extension was not explicitly registered, load it only if both helpers were registered
        if (!$container->has(Helper\UrlHelper::class) || !$container->has(Helper\ServerUrlHelper::class)) {
            return;
        }

        $extensionFactory = new Extension\UrlExtensionFactory();
        $engine->loadExtension($extensionFactory($container));
    }

    /**
     * Inject the Escaper extension.
     *
     * If a service by the name of the EscaperExtension class exists, fetches
     * and loads it.
     *
     * Otherwise, instantiates the EscaperExtensionFactory, and invokes it with
     * the container, loading the result into the engine.
     */
    private function injectEscaperExtension(ContainerInterface $container, Engine $engine): void
    {
        if ($container->has(Extension\EscaperExtension::class)) {
            $engine->loadExtension($container->get(Extension\EscaperExtension::class));
            return;
        }

        $extensionFactory = new Extension\EscaperExtensionFactory();
        $engine->loadExtension($extensionFactory($container));
    }

    /**
     * Inject all configured extensions into the engine.
     */
    private function injectExtensions(ContainerInterface $container, Engine $engine, array $extensions): void
    {
        foreach ($extensions as $extension) {
            $this->injectExtension($container, $engine, $extension);
        }
    }

    /**
     * Inject an extension into the engine.
     *
     * Valid extension specifications include:
     *
     * - ExtensionInterface instances
     * - String service names that resolve to ExtensionInterface instances
     * - String class names that resolve to ExtensionInterface instances
     *
     * If anything else is provided, an exception is raised.
     *
     * @param ExtensionInterface|string $extension
     * @throws Exception\InvalidExtensionException for non-string,
     *     non-extension $extension values.
     * @throws Exception\InvalidExtensionException for string $extension values
     *     that do not resolve to an extension instance.
     */
    private function injectExtension(ContainerInterface $container, Engine $engine, $extension): void
    {
        if ($extension instanceof ExtensionInterface) {
            $engine->loadExtension($extension);
            return;
        }

        if (!is_string($extension)) {
            throw new Exception\InvalidExtensionException(sprintf(
                '%s expects extension instances, service names, or class names; received %s',
                __CLASS__,
                (is_object($extension) ? get_class($extension) : gettype($extension))
            ));
        }

        if (!$container->has($extension) && !class_exists($extension)) {
            throw new Exception\InvalidExtensionException(sprintf(
                '%s expects extension service names or class names; "%s" does not resolve to either',
                __CLASS__,
                $extension
            ));
        }

        $extension = $container->has($extension)
            ? $container->get($extension)
            : new $extension();

        if (!$extension instanceof ExtensionInterface) {
            throw new Exception\InvalidExtensionException(sprintf(
                '%s expects extension services to implement %s ; received %s',
                __CLASS__,
                ExtensionInterface::class,
                (is_object($extension) ? get_class($extension) : gettype($extension))
            ));
        }

        $engine->loadExtension($extension);
    }
}

\Zend\View\HelperPluginManager factory

<?php

declare(strict_types=1);

namespace iMSCP\Layout\Plates;

use Psr\Container\ContainerInterface;
use Zend\View\Helper as ViewHelper;
use Zend\View\HelperPluginManager;

class ViewHelperManagerFactory
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $options = $options ?: [];
        $options['factories'] = isset($options['factories']) ? $options['factories'] : [];

        $plugins = new HelperPluginManager($container, $options);
        
        // Override extension factories
        $plugins = $this->injectOverrideFactories($plugins, $container);
        
        return $plugins;
    }

    /**
     * Inject override factories into the helper manager.
     *
     * @param HelperPluginManager $plugins
     * @param ContainerInterface $container
     * @return HelperPluginManager
     */
    private function injectOverrideFactories(HelperPluginManager $plugins, ContainerInterface $container)
    {
        // Configure base path helper
        $basePathFactory = $this->createBasePathHelperFactory($container);
        $plugins->setFactory(ViewHelper\BasePath::class, $basePathFactory);
        $plugins->setFactory('zendviewhelperbasepath', $basePathFactory);

        // Configure doctype plates extension
        $doctypeFactory = $this->createDoctypeHelperFactory($container);
        $plugins->setFactory(ViewHelper\Doctype::class, $doctypeFactory);
        $plugins->setFactory('zendviewhelperdoctype', $doctypeFactory);

        return $plugins;
    }

    /**
     * Create and return a factory for creating a BasePath helper.
     *
     * Uses configuration and request services to configure the helper.
     *
     * @param ContainerInterface $services
     * @return callable
     */
    private function createBasePathHelperFactory(ContainerInterface $services)
    {
        return function () use ($services) {
            $config = $services->has('config') ? $services->get('config') : [];
            $config = $config['plates']['zend_view_helpers'] ?? [];
            $helper = new ViewHelper\BasePath;

            //if (isset($config['plates']) && isset($config['plates']['base_path'])) {
            if (isset($config['base_path'])) {
                $helper->setBasePath($config['base_path']);
                return $helper;
            }

            # Should be done through dedicated middleware
            /*$request = $services->get('Request');
            if (is_callable([$request, 'getBasePath'])) {
                $helper->setBasePath($request->getBasePath());
            }*/

            return $helper;
        };
    }

    /**
     * Create and return a Doctype helper factory.
     *
     * Other plates extensions depend on this to decide which spec to generate their tags
     * based on. This is why it must be set early instead of later in the layout phtml.
     *
     * @param ContainerInterface $container
     * @return callable
     */
    private function createDoctypeHelperFactory(ContainerInterface $container)
    {
        return function () use ($container) {
            $config = $container->has('config') ? $container->get('config') : [];
            //$config = isset($config['plates']) ? $config['plates'] : [];
            $config = $config['plates']['zend_view_helpers'] ?? [];
            $helper = new ViewHelper\Doctype;
            if (isset($config['doctype']) && $config['doctype']) {
                $helper->setDoctype($config['doctype']);
            }
            return $helper;
        };
    }
}

Configuration provider

<?php

declare(strict_types=1);

namespace iMSCP\Layout;

final class ConfigProvider
{
    public function __invoke(): array
    {
        return [
            'dependencies'        => $this->getDependencies(),
            'plates'              => $this->getPlates(),
            'templates'           => $this->getTemplates(),
        ];
    }

    protected function getDependencies(): array
    {
        return [
            'delegators' => [
                \Zend\View\HelperPluginManager::class => [
                    \Zend\Navigation\View\ViewHelperManagerDelegatorFactory::class,
                ],
            ],
            'factories'  => [
                \League\Plates\Engine::class               => Plates\PlatesEngineFactory::class,
                \League\Plates\Extension\Asset::class      => Plates\Extension\AssetExtensionFactory::class,
                \Zend\View\HelperPluginManager::class      => Plates\ViewHelperManagerFactory::class,
            ],
        ];
    }

    protected function getNavigation()
    {
        return [
            'Admin'    => [

            ],
            'Reseller' => [

            ],
            'Client'   => [

            ]
        ];
    }

    protected function getPlates(): array
    {
        return [
            'zend_view_helpers' => [
                'base_path' => '/asset/default',
                'doctype'   => 'HTML5',
            ],
            'extensions'        => [
                \League\Plates\Extension\Asset::class,
            ]
        ];
    }

    protected function getTemplates()
    {
        return [
            'paths' => [
                'error'    => [__DIR__ . '/../templates/default/error'],
                'layout'   => [__DIR__ . '/../templates/default/layout'],
                'partials' => [__DIR__ . '/../templates/default/partials']
            ],
        ];
    }
}

Any feedback? Does something could be improved?

Have a Look here in the Forum:
https://discourse.zendframework.com/t/plates-and-zend-view-helpers/357/15

Thank you for your answer. I don’t like much the idea of passing a Zend View PhpRenderer instance as Plates template variable. I’ll have a look at the solution proposed by @ocramius which seem more accurate.

Regarding my solution, the Zend view plugin manager is better integrated, and the view helpers are registered as Plates functions only on their first invokation. Through there is really a few set of heplers which don’t work because they are too close to the Zend view PhpRenderer but I think for those, I could just provide my own implementations.

Once I’ll have a final solution, I’ll create a composer package such as zend-expressive-plates-zendviewhelpers and all the thing will work out-of-box by injecting the configuration provider only.