diff --git a/doc/book/template/interface.md b/doc/book/template/interface.md index 95f3f8d9..16b890d2 100644 --- a/doc/book/template/interface.md +++ b/doc/book/template/interface.md @@ -10,6 +10,11 @@ namespace Zend\Expressive\Template; interface TemplateInterface { /** + * Render a template, optionally with parameters. + * + * Implementations MUST support the `namespace::template` naming convention, + * and allow omitting the filename extension. + * * @param string $name * @param array|object $params * @return string @@ -17,12 +22,22 @@ interface TemplateInterface public function render($name, $params = []); /** + * Add a template path to the engine. + * + * Adds a template path, with optional namespace the templates in that path + * provide. + * * @param string $path * @param string $namespace */ public function addPath($path, $namespace = null); /** + * Add a template path to the engine. + * + * Adds a template path, with optional namespace the templates in that path + * provide. + * * @return TemplatePath[] */ public function getPaths(); @@ -34,13 +49,19 @@ interface TemplateInterface > Unfortunately, namespace syntax varies between different template engine > implementations. As an example: > -> - Plates uses the syntax `namespace::template` -> - Twig uses the syntax `@namespace/template` -> - zend-view does not natively support namespaces; we mimic it using normal -> directory syntax. +> - Plates uses the syntax `namespace::template`. +> - Twig uses the syntax `@namespace/template`. +> - zend-view does not natively support namespaces, though custom resolvers +> can provide the functionality. > -> As such, it's not entirely possible to have engine-agnostic templates if you -> use namespaces. +> To make different engines compatible, we require implementations to support +> the syntax `namespace::template` (where `namespace::` is optional) when +> rendering. Additionally, we require that engines allow omitting the filename +> suffix. +> +> When using a `TemplateInterface` implementation, feel free to use namespaced +> templates, and to omit the filename suffix; this will make your code portable +> and allow it to use alternate template engines. ## Paths @@ -53,10 +74,10 @@ the actual template. You may use the `addPath()` method to do so: $template->addPath('templates'); ``` -Many template engines further allow *namespacing* templates; when adding a path, -you specify the template *namespace* that it fulfills, and the engine will only -return a template from that path if the namespace provided matches the namespace -for the path. +Template engines adapted for zend-expressive are also required to allow +*namespacing* templates; when adding a path, you specify the template +*namespace* that it fulfills, and the engine will only return a template from +that path if the namespace provided matches the namespace for the path. ```php // Resolves to a path registered with the namespace "error"; @@ -81,9 +102,13 @@ of a template as the first argument: $content = $template->render('foo'); ``` -One key reason to use templates, however, is to dynamically provide data to -inject in the template. You may do so by passing either an associative array or -an object as the second argument to `render()`: +You can specify a namespaced template using the syntax `namespace::template`; +the `template` segment of the template name may use additional directory +separators when necessary. + +One key reason to use templates is to dynamically provide data to inject in the +template. You may do so by passing either an associative array or an object as +the second argument to `render()`: ```php $content = $template->render('message', [ diff --git a/doc/book/template/intro.md b/doc/book/template/intro.md index b6f57327..5ebfc408 100644 --- a/doc/book/template/intro.md +++ b/doc/book/template/intro.md @@ -6,9 +6,22 @@ very specific to the project and/or organization. We do, however, provide abstraction for templating via the interface `Zend\Expressive\Template\TemplateInterface`, which allows you to write -middleware that is engine-agnostic. In this documentation, we'll detail the -features of this interface, the various implementations we provide, and how you -can configure, inject, and consume templating in your middleware. +middleware that is engine-agnostic. For Expressive, this means: + +- All adapters MUST support template namespacing. Namespaces MUST be referenced + using the notation `namespace::template` when rendering. +- Adapters MUST allow rendering templates that omit the extension; they will, of + course, resolve to whatever default extension they require (or as configured). +- Adapters SHOULD allow passing an extension in the template name, but how that + is handled is left up to the adapter. +- Adapters SHOULD abstract layout capabilities. Many templating systems provide + this out of the box, or similar, compatible features such as template + inheritance. This should be transparent to end-users; they should be able to + simply render a template and assume it has the full content to return. + +In this documentation, we'll detail the features of this interface, the various +implementations we provide, and how you can configure, inject, and consume +templating in your middleware. We currently support: diff --git a/doc/book/template/zend-view.md b/doc/book/template/zend-view.md index af40b5e5..e4a806c7 100644 --- a/doc/book/template/zend-view.md +++ b/doc/book/template/zend-view.md @@ -56,6 +56,26 @@ $zendView->setResolver($resolver); $templates = new ZendView($zendView); ``` +> ### Namespaced path resolving +> +> Expressive defines a custom zend-view resolver, +> `Zend\Expressive\Template\ZendView\NamespacedPathStackResolver`. This resolver +> provides the ability to segregate paths by namespace, and later resolve a +> template according to the namespace, using the `namespace::template` notation +> required of `TemplateInterface` implementations. +> +> The ZendView adapter ensures that: +> +> - An AggregateResolver is registered with the renderer. If the registered +> resolver is not an AggregateResolver, it creates one and adds the original +> resolver to it. +> - A NamespacedPathStackResolver is registered with the AggregateResolver, at +> a low priority (0), ensuring attempts to resolve hit it later. +> +> With resolvers such as the TemplateMapResolver, you can also resolve +> namespaced templates, mapping them directly to the template on the filesystem +> that matches; adding such a resolver can be a nice performance boost! + ## Layouts Unlike the other supported template engines, zend-view does not support layouts diff --git a/phpcs.xml b/phpcs.xml index 9c5338ff..8de212f7 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -19,4 +19,6 @@ src test */test/Template/TestAsset/plates-null.php + */test/Template/TestAsset/test/test.js + */test/Template/TestAsset/test/test.php diff --git a/src/Template/TemplateInterface.php b/src/Template/TemplateInterface.php index 5d0f5a62..7899b4f6 100644 --- a/src/Template/TemplateInterface.php +++ b/src/Template/TemplateInterface.php @@ -15,6 +15,11 @@ interface TemplateInterface { /** + * Render a template, optionally with parameters. + * + * Implementations MUST support the `namespace::template` naming convention, + * and allow omitting the filename extension. + * * @param string $name * @param array|object $params * @return string @@ -22,12 +27,19 @@ interface TemplateInterface public function render($name, $params = []); /** + * Add a template path to the engine. + * + * Adds a template path, with optional namespace the templates in that path + * provide. + * * @param string $path * @param string $namespace */ public function addPath($path, $namespace = null); /** + * Retrieve configured paths from the engine. + * * @return TemplatePath[] */ public function getPaths(); diff --git a/src/Template/Twig.php b/src/Template/Twig.php index 9a9cb1f4..1aa7ebd3 100644 --- a/src/Template/Twig.php +++ b/src/Template/Twig.php @@ -20,6 +20,11 @@ class Twig implements TemplateInterface { use ArrayParametersTrait; + /** + * @var string + */ + private $suffix; + /** * @var TwigFilesystem */ @@ -35,7 +40,7 @@ class Twig implements TemplateInterface * * @param TwigEnvironment $template */ - public function __construct(TwigEnvironment $template = null) + public function __construct(TwigEnvironment $template = null, $suffix = 'html') { if (null === $template) { $template = $this->createTemplate($this->getDefaultLoader()); @@ -50,6 +55,7 @@ public function __construct(TwigEnvironment $template = null) $this->template = $template; $this->twigLoader = $loader; + $this->suffix = is_string($suffix) ? $suffix : 'html'; } /** @@ -82,6 +88,7 @@ private function getDefaultLoader() */ public function render($name, $params = []) { + $name = $this->normalizeTemplate($name); $params = $this->normalizeParams($params); return $this->template->render($name, $params); } @@ -115,4 +122,23 @@ public function getPaths() } return $paths; } + + /** + * Normalize namespaced template. + * + * Normalizes templates in the format "namespace::template" to + * "@namespace/template". + * + * @param string $template + * @return string + */ + public function normalizeTemplate($template) + { + $template = preg_replace('#^([^:]+)::(.*)$#', '@$1/$2', $template); + if (! preg_match('#\.[a-z]+$#i', $template)) { + return sprintf('%s.%s', $template, $this->suffix); + } + + return $template; + } } diff --git a/src/Template/ZendView.php b/src/Template/ZendView.php index 4e03e0bf..9d6b227f 100644 --- a/src/Template/ZendView.php +++ b/src/Template/ZendView.php @@ -13,12 +13,19 @@ use Zend\View\Model\ViewModel; use Zend\View\Renderer\RendererInterface; use Zend\View\Renderer\PhpRenderer; +use Zend\View\Resolver\AggregateResolver; use Zend\View\Resolver\ResolverInterface; -use Zend\View\Resolver\TemplatePathStack; use Zend\Expressive\Exception; /** * Template implementation bridging zendframework/zend-view. + * + * This implementation provides additional capabilities. + * + * First, it always ensures the resolver is an AggregateResolver, pushing any + * non-Aggregate into a new AggregateResolver instance. Additionally, it always + * registers a ZendView\NamespacedPathStackResolver at priority 0 (lower than + * default) in the Aggregate to ensure we can add and resolve namespaced paths. */ class ZendView implements TemplateInterface { @@ -40,7 +47,7 @@ class ZendView implements TemplateInterface private $renderer; /** - * @var ResolverInterface + * @var ZendView\NamespacedPathStackResolver */ private $resolver; @@ -67,9 +74,17 @@ public function __construct(RendererInterface $renderer = null, $layout = null) { if (null === $renderer) { $renderer = $this->createRenderer(); + $resolver = $renderer->resolver(); + } else { + $resolver = $renderer->resolver(); + if (! $resolver instanceof AggregateResolver) { + $aggregate = $this->getDefaultResolver(); + $aggregate->attach($resolver); + $resolver = $aggregate; + } elseif (! $this->hasNamespacedResolver($resolver)) { + $this->injectNamespacedResolver($resolver); + } } - $this->renderer = $renderer; - $this->resolver = $renderer->resolver(); if ($layout && is_string($layout)) { $model = new ViewModel(); @@ -85,29 +100,9 @@ public function __construct(RendererInterface $renderer = null, $layout = null) )); } - $this->layout = $layout; - } - - /** - * Returns a PhpRenderer object - * - * @return PhpRenderer - */ - private function createRenderer() - { - $render = new PhpRenderer(); - $render->setResolver($this->getDefaultResolver()); - return $render; - } - - /** - * Get the default resolver - * - * @return TemplatePathStack - */ - private function getDefaultResolver() - { - return new TemplatePathStack(); + $this->renderer = $renderer; + $this->resolver = $this->getNamespacedResolver($resolver); + $this->layout = $layout; } /** @@ -143,10 +138,7 @@ public function render($name, $params = []) */ public function addPath($path, $namespace = null) { - $this->resolver->addPath($path); - - // Normalize the path to be compliant with the TemplatePathStack - $this->paths[TemplatePathStack::normalizePath($path)] = $namespace; + $this->resolver->addPath($path, $namespace); } /** @@ -157,10 +149,20 @@ public function addPath($path, $namespace = null) public function getPaths() { $paths = []; - foreach ($this->resolver->getPaths() as $path) { - $namespace = array_key_exists($path, $this->paths) ? $this->paths[$path] : null; - $paths[] = new TemplatePath($path, $namespace); + + foreach ($this->resolver->getPaths() as $namespace => $namespacedPaths) { + if ($namespace === ZendView\NamespacedPathStackResolver::DEFAULT_NAMESPACE + || empty($namespace) + || is_int($namespace) + ) { + $namespace = null; + } + + foreach ($namespacedPaths as $path) { + $paths[] = new TemplatePath($path, $namespace); + } } + return $paths; } @@ -246,4 +248,68 @@ private function renderModel(ModelInterface $model, RendererInterface $renderer) return $renderer->render($model); } + + /** + * Returns a PhpRenderer object + * + * @return PhpRenderer + */ + private function createRenderer() + { + $renderer = new PhpRenderer(); + $renderer->setResolver($this->getDefaultResolver()); + return $renderer; + } + + /** + * Get the default resolver + * + * @return ZendView\NamespacedPathStackResolver + */ + private function getDefaultResolver() + { + $resolver = new AggregateResolver(); + $this->injectNamespacedResolver($resolver); + return $resolver; + } + + /** + * Attaches a new ZendView\NamespacedPathStackResolver to the AggregateResolver + * + * A priority of 0 is used, to ensure it is the last queried. + * + * @param AggregateResolver $aggregate + */ + private function injectNamespacedResolver(AggregateResolver $aggregate) + { + $aggregate->attach(new ZendView\NamespacedPathStackResolver(), 0); + } + + /** + * @param AggregateResolver $aggregate + * @return bool + */ + private function hasNamespacedResolver(AggregateResolver $aggregate) + { + foreach ($aggregate as $resolver) { + if ($resolver instanceof ZendView\NamespacedPathStackResolver) { + return true; + } + } + + return false; + } + + /** + * @param AggregateResolver $aggregate + * @return null|ZendView\NamespacedPathStackResolver + */ + private function getNamespacedResolver(AggregateResolver $aggregate) + { + foreach ($aggregate as $resolver) { + if ($resolver instanceof ZendView\NamespacedPathStackResolver) { + return $resolver; + } + } + } } diff --git a/src/Template/ZendView/NamespacedPathStackResolver.php b/src/Template/ZendView/NamespacedPathStackResolver.php new file mode 100644 index 00000000..f546e446 --- /dev/null +++ b/src/Template/ZendView/NamespacedPathStackResolver.php @@ -0,0 +1,226 @@ +useViewStream = (bool) ini_get('short_open_tag'); + if ($this->useViewStream) { + if (!in_array('zend.view', stream_get_wrappers())) { + stream_wrapper_register('zend.view', 'Zend\View\Stream'); + } + } + + if (null !== $options) { + $this->setOptions($options); + } + } + + /** + * Add a path to the stack with the given namespace. + * + * @param string $path + * @param string $namespace + * @throws ViewException\InvalidArgumentException for an invalid path + * @throws ViewException\InvalidArgumentException for an invalid namespace + */ + public function addPath($path, $namespace = self::DEFAULT_NAMESPACE) + { + if (! is_string($path)) { + throw new ViewException\InvalidArgumentException(sprintf( + 'Invalid path provided; expected a string, received %s', + gettype($path) + )); + } + + if (null === $namespace) { + $namespace = self::DEFAULT_NAMESPACE; + } + + if (! is_string($namespace) || empty($namespace)) { + throw new ViewException\InvalidArgumentException( + 'Invalid namespace provided; must be a non-empty string' + ); + } + + if (! array_key_exists($namespace, $this->paths)) { + $this->paths[$namespace] = new SplStack(); + } + + $this->paths[$namespace]->push(static::normalizePath($path)); + } + + /** + * Add many paths to the stack at once. + * + * @param array $paths + */ + public function addPaths(array $paths) + { + foreach ($paths as $namespace => $path) { + if (! is_string($namespace)) { + $namespace = self::DEFAULT_NAMESPACE; + } + + $this->addPath($path, $namespace); + } + } + + /** + * Overwrite all existing paths with the provided paths. + * + * @param array|Traversable $paths + * @throws ViewException\InvalidArgumentException for invalid path types. + */ + public function setPaths($paths) + { + if ($paths instanceof Traversable) { + $paths = iterator_to_array($paths); + } + + if (! is_array($paths)) { + throw new ViewException\InvalidArgumentException(sprintf( + 'Invalid paths provided; must be an array or Traversable, received %s', + (is_object($paths) ? get_class($paths) : gettype($paths)) + )); + } + + $this->clearPaths(); + $this->addPaths($paths); + } + + /** + * Clear all paths. + */ + public function clearPaths() + { + $this->paths = []; + } + + /** + * Retrieve the filesystem path to a view script + * + * @param string $name + * @param null|RendererInterface $renderer + * @return string + * @throws Exception\DomainException + */ + public function resolve($name, RendererInterface $renderer = null) + { + $namespace = self::DEFAULT_NAMESPACE; + $template = $name; + if (preg_match('#^(?P[^:]+)::(?P