diff --git a/doc/book/template/zend-view.md b/doc/book/template/zend-view.md index a4666813..afd30065 100644 --- a/doc/book/template/zend-view.md +++ b/doc/book/template/zend-view.md @@ -59,24 +59,80 @@ $templates = new ZendView($zendView); ## Layouts Unlike the other supported template engines, zend-view does not support layouts -out-of-the-box. +out-of-the-box. Expressive abstracts this fact away, providing two facilities +for doing so: -Layouts are accomplished in one of two ways: +- You may pass a layout template name or `Zend\View\Model\ModelInterface` + instance representing the layout as the second argument to the constructor. +- You may pass a "layout" parameter during rendering, with a value of either a + layout template name or a `Zend\View\Model\ModelInterface` + instance representing the layout. Passing a layout this way will override any + layout provided to the constructor. -- Multiple rendering passes: +In each case, the zend-view implementation will do a depth-first, recursive +render in order to provide content within the selected layout. - ```php - $content = $templates->render('blog/entry', [ 'entry' => $entry ]); - $layout = $templates->render('layout/layout', [ 'content' => $content ]); - ``` +### Layout name passed to constructor -- View models. To accomplish this, you will compose a view model for the - content, and pass it as a value to the layout: +```php +use Zend\Expressive\Template\ZendView; + +// Create the engine instance with a layout name: +$zendView = new PhpRenderer(null, 'layout'); +``` + +### Layout view model passed to constructor + +```php +use Zend\Expressive\Template\ZendView; +use Zend\View\Model\ViewModel + +// Create the layout view model: +$layout = new ViewModel([ + 'encoding' => 'utf-8', + 'cssPath' => '/css/prod/', +]); +$layout->setTemplate('layout'); + +// Create the engine instance with the layout: +$zendView = new PhpRenderer(null, $layout); +``` + +### Provide a layout name when rendering + +```php +$content = $templates->render('blog/entry', [ + 'layout' => 'blog', + 'entry' => $entry, +]); +``` + +### Provide a layout view model when rendering + +```php +use Zend\View\Model\ViewModel + +// Create the layout view model: +$layout = new ViewModel([ + 'encoding' => 'utf-8', + 'cssPath' => '/css/blog/', +]); +$layout->setTemplate('layout'); + +$content = $templates->render('blog/entry', [ + 'layout' => $layout, + 'entry' => $entry, +]); +``` + +## Recommendations + +We recommend the following practices when using the zend-view adapter: - ```php - use Zend\View\Model\ViewModel; - - $viewModel = new ViewModel(['entry' => $entry]); - $viewModel->setTemplate('blog/entry'); - $layout = $templates->render('layout/layout', [ 'content' => $viewModel ]); - ``` +- If using a layout, create a factory to return the layout view model as a + service; this allows you to inject it into middleware and add variables to it. +- While we support passing the layout as a rendering parameter, be aware that if + you change engines, this may not be supported. +- While you can use alternate resolvers, not all of them will work with the + `addPath()` implementation. As such, we recommend setting up resolvers and + paths only during creation of the template adapter. diff --git a/src/Exception/RenderingException.php b/src/Exception/RenderingException.php new file mode 100644 index 00000000..d4a36ced --- /dev/null +++ b/src/Exception/RenderingException.php @@ -0,0 +1,16 @@ +createRenderer(); + if (null === $renderer) { + $renderer = $this->createRenderer(); + } + $this->renderer = $renderer; + $this->resolver = $renderer->resolver(); + + if ($layout && is_string($layout)) { + $model = new ViewModel(); + $model->setTemplate($layout); + $layout = $model; + } + + if ($layout && ! $layout instanceof ModelInterface) { + throw new Exception\InvalidArgumentException(sprintf( + 'Layout must be a string layout template name or a %s instance; received %s', + ModelInterface::class, + (is_object($layout) ? get_class($layout) : gettype($layout)) + )); } - $this->template = $template; - $this->resolver = $template->resolver(); + + $this->layout = $layout; } /** @@ -72,7 +111,17 @@ private function getDefaultResolver() } /** - * Render + * Render a template with the given parameters. + * + * If a layout was specified during construction, it will be used; + * alternately, you can specify a layout to use via the "layout" + * parameter, using either: + * + * - a string layout template name + * - a Zend\View\Model\ModelInterface instance + * + * Layouts specified with $params take precedence over layouts passed to + * the constructor. * * @param string $name * @param array|object $params @@ -80,12 +129,14 @@ private function getDefaultResolver() */ public function render($name, $params = []) { - $params = $this->normalizeParams($params); - return $this->template->render($name, $params); + return $this->renderModel( + $this->createModel($name, $this->normalizeParams($params)), + $this->renderer + ); } /** - * Add a path for template + * Add a path for templates. * * @param string $path * @param string $namespace @@ -112,4 +163,87 @@ public function getPaths() } return $paths; } + + /** + * Create a view model from the template and parameters. + * + * Injects the created model in the layout view model, if present. + * + * If the $params contains a non-empty 'layout' key, that value will + * be used to seed a layout view model, if: + * + * - it is a string layout template name + * - it is a ModelInterface instance + * + * If a layout is discovered in this way, it will override the one set in + * the constructor, if any. + * + * @param string $name + * @param array $params + * @return ModelInterface + */ + private function createModel($name, array $params) + { + $layout = $this->layout ? clone $this->layout : null; + if (array_key_exists('layout', $params) && $params['layout']) { + if (is_string($params['layout'])) { + $layout = new ViewModel(); + $layout->setTemplate($params['layout']); + unset($params['layout']); + } elseif ($params['layout'] instanceof ModelInterface) { + $layout = $params['layout']; + unset($params['layout']); + } + } + + if (array_key_exists('layout', $params) && is_string($params['layout']) && $params['layout']) { + $layout = new ViewModel(); + $layout->setTemplate($params['layout']); + unset($params['layout']); + } + + $model = new ViewModel($params); + $model->setTemplate($name); + + if ($layout) { + $layout->addChild($model); + $model = $layout; + } + + return $model; + } + + /** + * Do a recursive, depth-first rendering of a view model. + * + * @param ModelInterface $model + * @param RendererInterface $renderer + * @return string + * @throws Exception\RenderingException if it encounters a terminal child. + */ + private function renderModel(ModelInterface $model, RendererInterface $renderer) + { + foreach ($model as $child) { + if ($child->terminate()) { + throw new Exception\RenderingException('Cannot render; encountered a child marked terminal'); + } + + $capture = $child->captureTo(); + if (empty($capture)) { + continue; + } + + $result = $this->renderModel($child, $renderer); + + if ($child->isAppend()) { + $oldResult = $model->{$capture}; + $model->setVariable($capture, $oldResult . $result); + continue; + } + + $model->setVariable($capture, $result); + } + + return $renderer->render($model); + } } diff --git a/test/Template/TestAsset/zendview-layout.phtml b/test/Template/TestAsset/zendview-layout.phtml new file mode 100644 index 00000000..0db1c312 --- /dev/null +++ b/test/Template/TestAsset/zendview-layout.phtml @@ -0,0 +1,8 @@ + + + + Layout Page + + +content ?> + diff --git a/test/Template/TestAsset/zendview-layout2.phtml b/test/Template/TestAsset/zendview-layout2.phtml new file mode 100644 index 00000000..0b71aa4b --- /dev/null +++ b/test/Template/TestAsset/zendview-layout2.phtml @@ -0,0 +1,8 @@ + + + + ALTERNATE LAYOUT PAGE + + +content ?> + diff --git a/test/Template/ZendViewTest.php b/test/Template/ZendViewTest.php index 9e2ae92a..95498af5 100644 --- a/test/Template/ZendViewTest.php +++ b/test/Template/ZendViewTest.php @@ -13,6 +13,7 @@ use PHPUnit_Framework_TestCase as TestCase; use Zend\Expressive\Template\ZendView; use Zend\Expressive\Exception; +use Zend\View\Model\ViewModel; use Zend\View\Renderer\PhpRenderer; use Zend\View\Resolver\TemplatePathStack; @@ -31,14 +32,14 @@ public function testCanPassRendererToConstructor() { $template = new ZendView($this->render); $this->assertInstanceOf(ZendView::class, $template); - $this->assertAttributeSame($this->render, 'template', $template); + $this->assertAttributeSame($this->render, 'renderer', $template); } public function testInstantiatingWithoutEngineLazyLoadsOne() { $template = new ZendView(); $this->assertInstanceOf(ZendView::class, $template); - $this->assertAttributeInstanceOf(PhpRenderer::class, 'template', $template); + $this->assertAttributeInstanceOf(PhpRenderer::class, 'renderer', $template); } public function testCanAddPathWithEmptyNamespace() @@ -135,4 +136,92 @@ public function testCanRenderWithParameterObjects($params, $search) $content = str_replace('', $search, $content); $this->assertEquals($content, $result); } + + /** + * @group layout + */ + public function testWillRenderContentInLayoutPassedToConstructor() + { + $template = new ZendView(null, 'zendview-layout'); + $template->addPath(__DIR__ . '/TestAsset'); + $name = 'ZendView'; + $result = $template->render('zendview', [ 'name' => $name ]); + $this->assertContains($name, $result); + $content = file_get_contents(__DIR__ . '/TestAsset/zendview.phtml'); + $content = str_replace('', $name, $content); + $this->assertContains($content, $result); + $this->assertContains('Layout Page', $result, sprintf("Received %s", $result)); + } + + /** + * @group layout + */ + public function testWillRenderContentInLayoutPassedDuringRendering() + { + $template = new ZendView(null); + $template->addPath(__DIR__ . '/TestAsset'); + $name = 'ZendView'; + $result = $template->render('zendview', [ 'name' => $name, 'layout' => 'zendview-layout' ]); + $this->assertContains($name, $result); + $content = file_get_contents(__DIR__ . '/TestAsset/zendview.phtml'); + $content = str_replace('', $name, $content); + $this->assertContains($content, $result); + + $this->assertContains('Layout Page', $result); + } + + /** + * @group layout + */ + public function testLayoutPassedWhenRenderingOverridesLayoutPassedToConstructor() + { + $template = new ZendView(null, 'zendview-layout'); + $template->addPath(__DIR__ . '/TestAsset'); + $name = 'ZendView'; + $result = $template->render('zendview', [ 'name' => $name, 'layout' => 'zendview-layout2' ]); + $this->assertContains($name, $result); + $content = file_get_contents(__DIR__ . '/TestAsset/zendview.phtml'); + $content = str_replace('', $name, $content); + $this->assertContains($content, $result); + + $this->assertContains('ALTERNATE LAYOUT PAGE', $result); + } + + /** + * @group layout + */ + public function testCanPassViewModelForLayoutToConstructor() + { + $layout = new ViewModel(); + $layout->setTemplate('zendview-layout'); + + $template = new ZendView(null, $layout); + $template->addPath(__DIR__ . '/TestAsset'); + $name = 'ZendView'; + $result = $template->render('zendview', [ 'name' => $name ]); + $this->assertContains($name, $result); + $content = file_get_contents(__DIR__ . '/TestAsset/zendview.phtml'); + $content = str_replace('', $name, $content); + $this->assertContains($content, $result); + $this->assertContains('Layout Page', $result, sprintf("Received %s", $result)); + } + + /** + * @group layout + */ + public function testCanPassViewModelForLayoutParameterWhenRendering() + { + $layout = new ViewModel(); + $layout->setTemplate('zendview-layout2'); + + $template = new ZendView(null, 'zendview-layout'); + $template->addPath(__DIR__ . '/TestAsset'); + $name = 'ZendView'; + $result = $template->render('zendview', [ 'name' => $name, 'layout' => $layout ]); + $this->assertContains($name, $result); + $content = file_get_contents(__DIR__ . '/TestAsset/zendview.phtml'); + $content = str_replace('', $name, $content); + $this->assertContains($content, $result); + $this->assertContains('ALTERNATE LAYOUT PAGE', $result); + } }