diff --git a/README.md b/README.md index 1f76fd26..c5740e36 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Full documentation can be found at [platesphp.com](https://platesphp.com/). ## Testing ```bash -phpunit +composer test ``` ## Contributing diff --git a/composer.json b/composer.json index de9f1f7d..98451032 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ } }, "scripts": { - "test": "phpunit --testdox", + "test": "phpunit --testdox --colors=always", "docs": "hugo -s doc server" } } diff --git a/doc/content/engine/themes.md b/doc/content/engine/themes.md new file mode 100644 index 00000000..7956ce8f --- /dev/null +++ b/doc/content/engine/themes.md @@ -0,0 +1,68 @@ ++++ +title = "Themes" +linkTitle = "Engine Themes" +[menu.main] +parent = "engine" +weight = 6 + [menu.main.params] + badge = "v3.5" ++++ + +Themes provide an alternative to template path resolution that allow for a holistic approach to template overrides and fallbacks. + +## Usage + +Given an engine configuration like: + +```php +use League\Plates\{Engine, Template\Theme}; + +$plates = Engine::fromTheme(Theme::hierarchy([ + Theme::new('/templates/main', 'Main'), // parent + Theme::new('/templates/user', 'User'), // child + Theme::new('/templates/seasonal', 'Seasonal'), // child2 +])); +``` + +And a file structure like: + +``` +templates/ + main/ + layout.php + home.php + header.php + user/ + layout.php + header.php + seasonal/ + header.php +``` + +The following looks ups, *regardless of where they are called from*, would resolve to the following files: + +```php +$templates->render('home'); // templates/main/home.php +$templates->render('layout'); // templates/user/layout.php +$templates->render('header'); // templates/seasonal/header.php +``` + +All paths are resolved from the last child to the first parent allowing a hierarchy of overrides. + +## Differences from Directory and Folders + +This logic is used **instead of** the directories and folders feature since they are distinct in nature, and combining the logic isn't obvious on how the features should stack. + +Creating an engine with one theme is functionally equivalent to using just a directory with no folders. + +The fallback functionality is a bit different however since with folders, it's *opt in*, you need to prefix the template name with the folder name. With themes, all template names implicitly will be resolved and fallback according to the hierarchy setup. + +## Additional Customization + +This functionality is powered by the `League\Plates\Template\ResolveTemplatePath` interface. If you'd prefer a more complex or specific path resolution, you can just implement your own and assign it to the engine instance with: + +```php +$plates = Engine::withResolveTemplatePath(new MyCustomResolveTemplatePath()); +``` + +The resolve template path should always resolve a string that represents a verified path on the filesystem or throw a TemplateNotFound exception. diff --git a/doc/layouts/_default/baseof.html b/doc/layouts/_default/baseof.html index 23003f36..c0cb81fe 100644 --- a/doc/layouts/_default/baseof.html +++ b/doc/layouts/_default/baseof.html @@ -71,7 +71,12 @@

{{ .Name }}

diff --git a/doc/static/css/custom.css b/doc/static/css/custom.css index a0d462fb..7361f083 100644 --- a/doc/static/css/custom.css +++ b/doc/static/css/custom.css @@ -21,3 +21,7 @@ .version-select { margin: 8px 25px 0px 45px; } + +.menu-badge { + color: #ff4143; +} \ No newline at end of file diff --git a/src/Engine.php b/src/Engine.php index 6c89642b..37867343 100644 --- a/src/Engine.php +++ b/src/Engine.php @@ -10,7 +10,9 @@ use League\Plates\Template\Func; use League\Plates\Template\Functions; use League\Plates\Template\Name; +use League\Plates\Template\ResolveTemplatePath; use League\Plates\Template\Template; +use League\Plates\Template\Theme; /** * Template API and environment settings storage. @@ -47,6 +49,9 @@ class Engine */ protected $data; + /** @var ResolveTemplatePath */ + private $resolveTemplatePath; + /** * Create new Engine instance. * @param string $directory @@ -59,6 +64,25 @@ public function __construct($directory = null, $fileExtension = 'php') $this->folders = new Folders(); $this->functions = new Functions(); $this->data = new Data(); + $this->resolveTemplatePath = new ResolveTemplatePath\NameAndFolderResolveTemplatePath(); + } + + public static function fromTheme(Theme $theme, string $fileExtension = 'php'): self { + $engine = new self(null, $fileExtension); + $engine->setResolveTemplatePath(new ResolveTemplatePath\ThemeResolveTemplatePath($theme)); + return $engine; + } + + public function setResolveTemplatePath(ResolveTemplatePath $resolveTemplatePath) + { + $this->resolveTemplatePath = $resolveTemplatePath; + + return $this; + } + + public function getResolveTemplatePath(): ResolveTemplatePath + { + return $this->resolveTemplatePath; } /** diff --git a/src/Exception/TemplateNotFound.php b/src/Exception/TemplateNotFound.php new file mode 100644 index 00000000..8e329380 --- /dev/null +++ b/src/Exception/TemplateNotFound.php @@ -0,0 +1,23 @@ +template = $template; + $this->paths = $paths; + parent::__construct($message); + } + + public function template(): string { + return $this->template; + } + + public function paths(): array { + return $this->paths; + } +} diff --git a/src/Template/ResolveTemplatePath.php b/src/Template/ResolveTemplatePath.php new file mode 100644 index 00000000..84d62b44 --- /dev/null +++ b/src/Template/ResolveTemplatePath.php @@ -0,0 +1,13 @@ +getPath(); + if (is_file($path)) { + return $path; + } + + throw new TemplateNotFound($name->getName(), [$name->getPath()], 'The template "' . $name->getName() . '" could not be found at "' . $name->getPath() . '".'); + } +} diff --git a/src/Template/ResolveTemplatePath/ThemeResolveTemplatePath.php b/src/Template/ResolveTemplatePath/ThemeResolveTemplatePath.php new file mode 100644 index 00000000..f4c2786f --- /dev/null +++ b/src/Template/ResolveTemplatePath/ThemeResolveTemplatePath.php @@ -0,0 +1,41 @@ +theme = $theme; + } + + public function __invoke(Name $name): string { + $searchedPaths = []; + foreach ($this->theme->listThemeHierarchy() as $theme) { + $path = $theme->dir() . '/' . $name->getName() . '.' . $name->getEngine()->getFileExtension(); + if (is_file($path)) { + return $path; + } + $searchedPaths[] = [$theme->name(), $path]; + } + + throw new TemplateNotFound( + $name->getName(), + array_map(function(array $tup) { + return $tup[1]; + }, $searchedPaths), + sprintf('The template "%s" was not found in the following themes: %s', + $name->getName(), + implode(', ', array_map(function(array $tup) { + return implode(':', $tup); + }, $searchedPaths)) + ) + ); + } +} diff --git a/src/Template/Template.php b/src/Template/Template.php index 64ee4387..2027e515 100644 --- a/src/Template/Template.php +++ b/src/Template/Template.php @@ -4,6 +4,7 @@ use Exception; use League\Plates\Engine; +use League\Plates\Exception\TemplateNotFound; use LogicException; use Throwable; @@ -126,7 +127,12 @@ public function data(array $data = null) */ public function exists() { - return $this->name->doesPathExist(); + try { + ($this->engine->getResolveTemplatePath())($this->name); + return true; + } catch (TemplateNotFound $e) { + return false; + } } /** @@ -135,7 +141,11 @@ public function exists() */ public function path() { - return $this->name->getPath(); + try { + return ($this->engine->getResolveTemplatePath())($this->name); + } catch (TemplateNotFound $e) { + return $e->paths()[0]; + } } /** @@ -151,17 +161,13 @@ public function render(array $data = array()) unset($data); extract($this->data); - if (!$this->exists()) { - throw new LogicException( - 'The template "' . $this->name->getName() . '" could not be found at "' . $this->path() . '".' - ); - } + $path = ($this->engine->getResolveTemplatePath())($this->name); try { $level = ob_get_level(); ob_start(); - include $this->path(); + include $path; $content = ob_get_clean(); @@ -177,12 +183,6 @@ public function render(array $data = array()) ob_end_clean(); } - throw $e; - } catch (Exception $e) { - while (ob_get_level() > $level) { - ob_end_clean(); - } - throw $e; } } diff --git a/src/Template/Theme.php b/src/Template/Theme.php new file mode 100644 index 00000000..80082fda --- /dev/null +++ b/src/Template/Theme.php @@ -0,0 +1,80 @@ +dir = $dir; + $this->name = $name; + } + + /** @param Theme[] $themes */ + public static function hierarchy(array $themes): Theme { + self::assertThemesForHierarchyAreNotEmpty($themes); + self::assertAllThemesInHierarchyAreLeafThemes($themes); + + /** @var Theme $theme */ + $theme = array_reduce(array_slice($themes, 1), function(Theme $parent, Theme $child) { + $child->next = $parent; + return $child; + }, $themes[0]); + self::assertHierarchyContainsUniqueThemeNames($theme); + return $theme; + } + + public static function new(string $dir, string $name = 'Default'): self { + return new self($dir, $name); + } + + public function dir(): string { + return $this->dir; + } + + public function name(): string { + return $this->name; + } + + /** + * list all directories in the hierarchy from first to last + * @return Theme[] + */ + public function listThemeHierarchy(): \Generator { + yield $this; + if ($this->next) { + yield from $this->next->listThemeHierarchy(); + } + } + + /** @param Theme[] $themes */ + private static function assertThemesForHierarchyAreNotEmpty(array $themes) { + if (count($themes) === 0) { + throw new \RuntimeException('Empty theme hierarchies are not allowed.'); + } + } + + /** @param Theme[] $themes */ + private static function assertAllThemesInHierarchyAreLeafThemes(array $themes) { + foreach ($themes as $theme) { + if ($theme->next) { + throw new \RuntimeException('Nested theme hierarchies are not allowed, make sure to use Theme::new when creating themes in your hierarchy. Theme ' . $theme->name . ' is already in a hierarchy.'); + } + } + } + + private static function assertHierarchyContainsUniqueThemeNames(Theme $theme) { + $names = []; + foreach ($theme->listThemeHierarchy() as $theme) { + $names[] = $theme->name; + } + + if (count(array_unique($names)) !== count($names)) { + throw new \RuntimeException('Duplicate theme names in hierarchies are not allowed. Received theme names: [' . implode(', ', $names) . '].'); + } + } +} diff --git a/tests/Template/ThemeTest.php b/tests/Template/ThemeTest.php new file mode 100644 index 00000000..ef60c7ec --- /dev/null +++ b/tests/Template/ThemeTest.php @@ -0,0 +1,127 @@ +given_a_directory_structure_is_setup_like('templates', ['main.php' => '']); + $this->given_an_engine_is_created_with_theme(Theme::new($this->vfsPath('templates'))); + $this->when_the_engine_renders('main'); + $this->then_the_rendered_template_matches(''); + } + + /** @test */ + public function engine_renders_with_theme_hierarchy() { + $this->given_a_directory_structure_is_setup_like('templates', [ + 'parent' => [ + 'main.php' => 'layout("layout") ?>parent', + 'layout.php' => 'parent: section("content")?>' + ], + 'child' => [ + 'layout.php' => 'child: section("content")?>' + ], + ]); + $this->given_an_engine_is_created_with_theme(Theme::hierarchy([ + Theme::new($this->vfsPath('templates/parent'), 'Parent'), + Theme::new($this->vfsPath('templates/child'), 'Child'), + ])); + $this->when_the_engine_renders('main'); + $this->then_the_rendered_template_matches('child: parent'); + } + + /** @test */ + public function duplicate_theme_names_in_hierarchies_are_not_allowed() { + $this->when_a_theme_is_created_like(function() { + Theme::hierarchy([ + Theme::new('templates/a'), + Theme::new('templates/b'), + ]); + }); + $this->then_an_exception_is_thrown_with_message('Duplicate theme names in hierarchies are not allowed. Received theme names: [Default, Default].'); + } + + /** @test */ + public function nested_hierarchies_are_not_allowed() { + $this->when_a_theme_is_created_like(function() { + Theme::hierarchy([ + Theme::hierarchy([Theme::new('templates', 'A'), Theme::new('templates', 'B')]) + ]); + }); + $this->then_an_exception_is_thrown_with_message('Nested theme hierarchies are not allowed, make sure to use Theme::new when creating themes in your hierarchy. Theme B is already in a hierarchy.'); + } + + /** @test */ + public function empty_hierarchies_are_not_allowed() { + $this->when_a_theme_is_created_like(function() { + Theme::hierarchy([]); + }); + $this->then_an_exception_is_thrown_with_message('Empty theme hierarchies are not allowed.'); + } + + /** @test */ + public function template_not_found_errors_reference_themes_checked() { + $this->given_a_directory_structure_is_setup_like('templates', []); + $this->given_an_engine_is_created_with_theme(Theme::hierarchy([ + Theme::new($this->vfsPath('templates/one'), 'One'), + Theme::new($this->vfsPath('templates/two'), 'Two'), + ])); + $this->when_the_engine_renders('main'); + $this->then_an_exception_is_thrown_with_message('The template "main" was not found in the following themes: Two:vfs://templates/two/main.php, One:vfs://templates/one/main.php'); + } + + private function given_a_directory_structure_is_setup_like(string $rootDir, array $directoryStructure) { + vfsStream::setup($rootDir); + vfsStream::create($directoryStructure); + } + + private function given_an_engine_is_created_with_theme(Theme $theme) { + $this->engine = \League\Plates\Engine::fromTheme($theme); + } + + private function when_a_theme_is_created_like(callable $fn) { + try { + $fn(); + } catch (\Throwable $e) { + $this->exception = $e; + } + } + + private function vfsPath(string $path): string { + return vfsStream::url($path); + } + + private function when_the_engine_renders(string $templateName, array $data = []) { + try { + $this->result = $this->engine->render($templateName, $data); + } catch (\Throwable $e) { + $this->exception = $e; + } + } + + private function then_the_rendered_template_matches(string $expected) { + if ($this->exception) { + throw $this->exception; + } + + $this->assertEquals($expected, $this->result); + } + + private function then_an_exception_is_thrown_with_message(string $expectedMessage) { + $this->assertNotNull($this->exception, 'Expected an exception to be thrown with message: ' . $expectedMessage); + $this->assertEquals($expectedMessage, $this->exception->getMessage(), 'Expected an exception to be thrown with message: ' . $expectedMessage); + } +}