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 }}
{{ range .Children }}
{{ end }}
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: =$this->section("content")?>'
+ ],
+ 'child' => [
+ 'layout.php' => 'child: =$this->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);
+ }
+}