From 607226a3a49c64d5f7c16828652ab884c0bbe360 Mon Sep 17 00:00:00 2001 From: Abdy Franco Date: Mon, 3 Aug 2026 16:48:59 -0600 Subject: [PATCH] Allow plugins to override template directory --- src/Lib/View.php | 74 ++++++- .../plugins/my_plugin/views/default/main.pdt | 1 + .../plugins/my_plugin/views/default/other.pdt | 1 + .../my_plugin/views/my_other_dir/main.pdt | 1 + .../my_plugin/views/my_template/main.pdt | 1 + tests/Unit/Lib/ViewTest.php | 209 ++++++++++++++++++ 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/main.pdt create mode 100644 tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/other.pdt create mode 100644 tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_other_dir/main.pdt create mode 100644 tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_template/main.pdt create mode 100644 tests/Unit/Lib/ViewTest.php diff --git a/src/Lib/View.php b/src/Lib/View.php index c92f16e..f813bd0 100644 --- a/src/Lib/View.php +++ b/src/Lib/View.php @@ -33,6 +33,22 @@ class View extends Language */ public $default_view; + /** + * @var string An optional view directory, within the view path, preferred over $view. + * Set to allow individual views to be overridden for an active template, any view the + * template directory does not contain falls back to $view. + * @see View::setTemplate() + */ + public $template; + + /** + * @var string The view directory $template may override. $template is only applied while + * $view matches this, so that a view which has deliberately moved to a directory of its + * own is never overridden. + * @see View::setTemplate() + */ + public $template_view; + /** * @var string This view's relative path */ @@ -92,6 +108,23 @@ final public function setDefaultView($path) $this->view_path = $path; } + /** + * Sets a view directory to prefer over $view, per file, while $view is $template_view. + * + * Any view the template directory does not contain falls back to $template_view, so a + * template may override as few or as many views as it likes. The override is limited to + * $template_view so that a view which has since moved to a directory of its own is left + * alone rather than being silently overridden. + * + * @param string $template The view directory to prefer, within the view path + * @param string $view The view directory $template may override + */ + final public function setTemplate($template, $view) + { + $this->template = $template; + $this->template_view = $view; + } + /** * Sets the view file and view to be used for this View * @@ -109,7 +142,19 @@ final public function setView($file = null, $view = null) list($view_path, $view) = $this->getViewPath($view); $this->view = $view; $this->view_path = $view_path; - $this->view_dir = str_replace( + $this->view_dir = $this->buildViewDir($view_path, $view); + } + + /** + * Builds the web accessible directory for the given view path and view + * + * @param string $view_path The view path the view resides in + * @param string $view The view directory + * @return string The view directory relative to the public web directory + */ + private function buildViewDir($view_path, $view) + { + return str_replace( "\\", "/", str_replace( @@ -157,6 +202,33 @@ final public function fetch($file = null, $view = null) . $this->view_path . 'views' . DIRECTORY_SEPARATOR . $this->view . DIRECTORY_SEPARATOR . $this->file . $this->view_ext; + // Prefer a view of the same name from the template directory, when one is set and this + // view is still the one it may override. This allows individual views to be overridden + // for the active template, while any view the template directory does not contain falls + // back to the view directory below. + if (!empty($this->template) + && $this->template !== $this->view + && $this->template_view === $this->view + ) { + $template_file = $this->container->get('minphp.constants')['ROOTWEBDIR'] + . $this->view_path . 'views' . DIRECTORY_SEPARATOR + . $this->template . DIRECTORY_SEPARATOR . $this->file . $this->view_ext; + + // See the note below regarding macOS and symbolic links + if (!file_exists($template_file) && strpos(strtolower(PHP_OS), 'darwin') !== false) { + $template_file = $this->view_path . 'views' . DIRECTORY_SEPARATOR + . $this->template . DIRECTORY_SEPARATOR . $this->file . $this->view_ext; + } + + if (file_exists($template_file)) { + $file = $template_file; + + // Point the view directory at the template so that any asset the view + // references resolves against the directory the view was loaded from + $this->view_dir = $this->buildViewDir($this->view_path, $this->template); + } + } + if (is_array($this->vars)) { if (isset($this->vars['file'])) { unset($this->vars['file']); diff --git a/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/main.pdt b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/main.pdt new file mode 100644 index 0000000..65b4a2a --- /dev/null +++ b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/main.pdt @@ -0,0 +1 @@ +DEFAULT-MAIN|view_dir; ?> \ No newline at end of file diff --git a/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/other.pdt b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/other.pdt new file mode 100644 index 0000000..91807b5 --- /dev/null +++ b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/default/other.pdt @@ -0,0 +1 @@ +DEFAULT-OTHER|view_dir; ?> \ No newline at end of file diff --git a/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_other_dir/main.pdt b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_other_dir/main.pdt new file mode 100644 index 0000000..8567472 --- /dev/null +++ b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_other_dir/main.pdt @@ -0,0 +1 @@ +OTHER-DIR-MAIN|view_dir; ?> \ No newline at end of file diff --git a/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_template/main.pdt b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_template/main.pdt new file mode 100644 index 0000000..2ca3419 --- /dev/null +++ b/tests/Unit/Lib/Fixtures/App/plugins/my_plugin/views/my_template/main.pdt @@ -0,0 +1 @@ +TEMPLATE-MAIN|view_dir; ?> \ No newline at end of file diff --git a/tests/Unit/Lib/ViewTest.php b/tests/Unit/Lib/ViewTest.php new file mode 100644 index 0000000..3592674 --- /dev/null +++ b/tests/Unit/Lib/ViewTest.php @@ -0,0 +1,209 @@ +fixtureDir = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'Fixtures' + . DIRECTORY_SEPARATOR . 'App' . DIRECTORY_SEPARATOR; + $this->pluginPath = 'plugins' . DIRECTORY_SEPARATOR . 'my_plugin' . DIRECTORY_SEPARATOR; + + $fixtureDir = $this->fixtureDir; + + $init = Initializer::get(); + $container = new Container(); + + $container->set('minphp.constants', function () use ($fixtureDir) { + return [ + 'ROOTWEBDIR' => $fixtureDir, + 'PLUGINDIR' => $fixtureDir . 'plugins' . DIRECTORY_SEPARATOR, + 'WEBDIR' => $fixtureDir, + 'APPDIR' => 'app' . DIRECTORY_SEPARATOR + ]; + }); + $container->set('minphp.mvc', function () { + return [ + 'default_controller' => 'main', + 'default_structure' => 'structure', + 'default_view' => 'default', + 'view_extension' => '.pdt', + 'cli_render_views' => false, + '404_forwarding' => false, + 'error_view' => 'errors' + ]; + }); + + $init->setContainer($container); + } + + /** + * Builds a View pointed at the fixture plugin, the way Dispatcher and a plugin + * controller leave it + * + * @param string $template The template directory to prefer, if any + * @return View + */ + protected function getPluginView($template = null, $template_view = 'default') + { + $view = new View(); + $view->setDefaultView($this->pluginPath); + $view->view = 'default'; + + if (null !== $template) { + $view->setTemplate($template, $template_view); + } + + return $view; + } + + /** + * Returns the view directory the given rendered output was loaded from + * + * @param string $output The rendered view + * @return array The marker the view printed and the view directory it reported + */ + protected function parse($output) + { + return explode('|', $output); + } + + /** + * @covers ::fetch + * @covers ::setView + * @covers ::setDefaultView + * @covers ::buildViewDir + * @uses \View::__construct + * Tests that a view provided by the template directory is rendered from it + */ + public function testFetchPrefersTemplate() + { + list($marker, $view_dir) = $this->parse($this->getPluginView('my_template')->fetch('main')); + + $this->assertEquals('TEMPLATE-MAIN', $marker); + $this->assertStringEndsWith('plugins/my_plugin/views/my_template/', $view_dir); + } + + /** + * @covers ::fetch + * @covers ::setView + * @covers ::buildViewDir + * @uses \View::__construct + * @uses \View::setDefaultView + * Tests that a view the template directory does not provide falls back to the view + * directory, even though the template directory provides other views + */ + public function testFetchFallsBackPerFile() + { + $view = $this->getPluginView('my_template'); + + list($marker) = $this->parse($view->fetch('main')); + $this->assertEquals('TEMPLATE-MAIN', $marker); + + list($marker, $view_dir) = $this->parse($view->fetch('other')); + $this->assertEquals('DEFAULT-OTHER', $marker); + $this->assertStringEndsWith('plugins/my_plugin/views/default/', $view_dir); + } + + /** + * @covers ::fetch + * @covers ::setView + * @covers ::buildViewDir + * @uses \View::__construct + * @uses \View::setDefaultView + * Tests that no template leaves the view directory in charge + */ + public function testFetchWithoutTemplate() + { + list($marker, $view_dir) = $this->parse($this->getPluginView()->fetch('main')); + + $this->assertEquals('DEFAULT-MAIN', $marker); + $this->assertStringEndsWith('plugins/my_plugin/views/default/', $view_dir); + } + + /** + * @covers ::fetch + * @covers ::setView + * @covers ::buildViewDir + * @uses \View::__construct + * @uses \View::setDefaultView + * Tests that a template matching the view directory is a no-op + */ + public function testFetchWithTemplateMatchingView() + { + list($marker, $view_dir) = $this->parse($this->getPluginView('default')->fetch('main')); + + $this->assertEquals('DEFAULT-MAIN', $marker); + $this->assertStringEndsWith('plugins/my_plugin/views/default/', $view_dir); + } + + /** + * @covers ::fetch + * @covers ::setView + * @covers ::buildViewDir + * @uses \View::__construct + * @uses \View::setDefaultView + * @uses \View::setTemplate + * Tests that a template directory which does not exist leaves the view directory in charge + */ + public function testFetchWithUnknownTemplate() + { + list($marker, $view_dir) = $this->parse($this->getPluginView('no_such_template')->fetch('main')); + + $this->assertEquals('DEFAULT-MAIN', $marker); + $this->assertStringEndsWith('plugins/my_plugin/views/default/', $view_dir); + } + + /** + * @covers ::fetch + * @covers ::setTemplate + * @covers ::setView + * @covers ::buildViewDir + * @uses \View::__construct + * @uses \View::setDefaultView + * Tests that a view which has moved to a directory of its own is not overridden, even + * though the template directory contains a view of the same name + */ + public function testFetchLeavesViewOutsideTemplateViewAlone() + { + $view = $this->getPluginView('my_template'); + $view->view = 'my_other_dir'; + + list($marker, $view_dir) = $this->parse($view->fetch('main')); + + $this->assertEquals('OTHER-DIR-MAIN', $marker); + $this->assertStringEndsWith('plugins/my_plugin/views/my_other_dir/', $view_dir); + } + + /** + * @covers ::setTemplate + * @uses \View::__construct + * Tests that the template and the view it may override are both recorded + */ + public function testSetTemplate() + { + $view = new View(); + $view->setTemplate('my_template', 'default'); + + $this->assertEquals('my_template', $view->template); + $this->assertEquals('default', $view->template_view); + } +}