Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions benchmarks/ProjectThemeDiscoveryBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace YiiPress\Benchmarks;

use PhpBench\Attributes\AfterMethods;
use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;
use PhpBench\Attributes\Warmup;
use YiiPress\Build\ProjectThemeDiscovery;
use YiiPress\Build\Theme;
use YiiPress\Build\ThemeRegistry;

#[BeforeMethods('setUp')]
#[AfterMethods('tearDown')]
final class ProjectThemeDiscoveryBench
{
private string $rootDir;
private ProjectThemeDiscovery $discovery;

public function setUp(): void
{
$this->rootDir = sys_get_temp_dir() . '/yiipress-project-theme-bench-' . uniqid();
mkdir($this->rootDir . '/themes', 0o755, true);

for ($i = 1; $i <= 50; $i++) {
mkdir($this->rootDir . '/themes/theme-' . $i);
}

$this->discovery = new ProjectThemeDiscovery();
}

public function tearDown(): void
{
$this->removeDir($this->rootDir);
}

#[Revs(100)]
#[Iterations(3)]
#[Warmup(1)]
public function benchRegisterProjectThemes(): void
{
$registry = new ThemeRegistry();
$registry->register(new Theme('minimal', '/built-in/minimal'));

$this->discovery->register($registry, $this->rootDir . '/themes');
}

private function removeDir(string $path): void
{
if (!is_dir($path)) {
return;
}

$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}

rmdir($path);
}
}
5 changes: 2 additions & 3 deletions docs/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ The source-open overlay resolves the browser path through the build manifest, ve

## Theme Registration

Project-local templates under `content/templates/` are registered automatically as the `local` theme. Engine-level or distributable themes are registered in [Yii3 DI](https://yiisoft.github.io/docs/guide/concept/di-container.html):
Project themes under `<project>/themes/<name>/` are registered automatically by directory name. Project-local templates under `content/templates/` are registered automatically as the `local` theme. Engine-level themes may still be registered in [Yii3 DI](https://yiisoft.github.io/docs/guide/concept/di-container.html):

```php
use YiiPress\Build\Theme;
Expand All @@ -204,11 +204,10 @@ return [
ThemeRegistry::class => DynamicReference::to(static function (): ThemeRegistry {
$registry = new ThemeRegistry();
$registry->register(new Theme('minimal', dirname(__DIR__, 3) . '/themes/minimal'));
$registry->register(new Theme('fancy', '/path/to/fancy-theme'));

return $registry;
}),
];
```

Template resolution checks the active theme first, then falls back through registered themes. This lets a project override one template while keeping the rest of the bundled theme.
For binary users, install a reusable theme as `themes/fancy/` and set `theme: fancy` in `content/config.yaml`. Template resolution checks the active theme first, then falls back through registered themes. This lets a project override one template while keeping the rest of the bundled theme.
25 changes: 23 additions & 2 deletions docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Use `$h()` for text that should be escaped. Rendered Markdown content in `$conte

## Themes

A theme is a named set of template files. YiiPress ships with the built-in `minimal` theme. A project-local `content/templates/` directory is automatically available as the `local` theme.
A theme is a named set of template files. YiiPress ships with the built-in `minimal` theme. Project themes under `themes/<name>/` are registered automatically, and a project-local `content/templates/` directory is automatically available as the `local` theme.

### Theme resolution order

Expand All @@ -53,7 +53,28 @@ When YiiPress renders a page, it chooses templates in this order:
2. **Site-level default theme** — set via `theme` in `config.yaml`.
3. **Built-in `minimal` theme** — fallback when a template is missing.

Within a theme, YiiPress uses the requested file when it exists and falls back to other registered themes when it does not. That means a local theme can override only `entry.php` and keep every other page type from `minimal`.
Within a theme, YiiPress uses the requested file when it exists and falls back to other registered themes when it does not. That means a project theme or local theme can override only `entry.php` and keep every other page type from `minimal`.

### Project themes

Install reusable themes into the project root:

```
themes/
└── docs/
├── entry.php
├── partials/
├── assets/
└── translation/
```

Use the directory name as the theme name:

```yaml
theme: docs
```

Theme directory names may contain letters, numbers, `_`, and `-`, and must start with a letter or number. If a project theme has the same name as an already registered built-in theme, the built-in theme is kept.

### Local theme

Expand Down
1 change: 1 addition & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
## Priority 4: Templates and theming

- [x] Theme system — installable/distributable themes
- [x] Auto-register project themes from `themes/<name>/` for binary users
- [x] Template partials/includes support
- [x] Template helper functions documentation
- [x] Multiple layout support (per-entry layout selection via front matter)
Expand Down
66 changes: 66 additions & 0 deletions src/Build/ProjectThemeDiscovery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace YiiPress\Build;

use FilesystemIterator;
use SplFileInfo;

use function array_values;
use function is_dir;
use function ksort;
use function preg_match;

final readonly class ProjectThemeDiscovery
{
/**
* @return list<Theme>
*/
public function discover(string $themesDir): array
{
if (!is_dir($themesDir)) {
return [];
}

$themes = [];
$iterator = new FilesystemIterator($themesDir, FilesystemIterator::SKIP_DOTS);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
if (!$item->isDir()) {
continue;
}

$name = $item->getFilename();
if (!$this->isValidThemeName($name)) {
continue;
}

$themes[$name] = new Theme($name, $item->getPathname());
}

ksort($themes);

return array_values($themes);
}

public function register(ThemeRegistry $registry, string $themesDir): int
{
$registered = 0;
foreach ($this->discover($themesDir) as $theme) {
if ($registry->has($theme->name)) {
continue;
}

$registry->register($theme);
$registered++;
}

return $registered;
}

private function isValidThemeName(string $name): bool
{
return preg_match('/^[A-Za-z0-9][A-Za-z0-9_-]*$/D', $name) === 1;
}
}
3 changes: 3 additions & 0 deletions src/Console/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use YiiPress\Build\DateArchiveWriter;
use YiiPress\Build\NotFoundPageWriter;
use YiiPress\Build\NavigationPager;
use YiiPress\Build\ProjectThemeDiscovery;
use YiiPress\Build\RedirectPageWriter;
use YiiPress\Build\RobotsTxtGenerator;
use YiiPress\Build\SearchIndexGenerator;
Expand Down Expand Up @@ -219,6 +220,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return ExitCode::DATAERR;
}

(new ProjectThemeDiscovery())->register($this->themeRegistry, $rootPath . '/themes');

$localTemplatesDir = $contentDir . '/templates';
if (is_dir($localTemplatesDir)) {
$this->themeRegistry->register(new Theme('local', $localTemplatesDir));
Expand Down
100 changes: 100 additions & 0 deletions tests/Unit/Build/ProjectThemeDiscoveryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace YiiPress\Tests\Unit\Build;

use FilesystemIterator;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use YiiPress\Build\ProjectThemeDiscovery;
use YiiPress\Build\Theme;
use YiiPress\Build\ThemeRegistry;

use function PHPUnit\Framework\assertCount;
use function PHPUnit\Framework\assertSame;

final class ProjectThemeDiscoveryTest extends TestCase
{
private string $rootDir;

protected function setUp(): void
{
$this->rootDir = sys_get_temp_dir() . '/yiipress-project-themes-test-' . uniqid();
mkdir($this->rootDir . '/themes', 0o755, true);
}

protected function tearDown(): void
{
$this->removeDir($this->rootDir);
}

public function testDiscoversProjectThemesInDeterministicOrder(): void
{
mkdir($this->rootDir . '/themes/zeta');
mkdir($this->rootDir . '/themes/alpha');
mkdir($this->rootDir . '/themes/docs_theme');
file_put_contents($this->rootDir . '/themes/readme.md', 'not a theme');

$themes = (new ProjectThemeDiscovery())->discover($this->rootDir . '/themes');

assertSame(['alpha', 'docs_theme', 'zeta'], array_map(static fn (Theme $theme): string => $theme->name, $themes));
}

public function testSkipsInvalidThemeNames(): void
{
mkdir($this->rootDir . '/themes/good-theme');
mkdir($this->rootDir . '/themes/.hidden');
mkdir($this->rootDir . '/themes/bad.name');

$themes = (new ProjectThemeDiscovery())->discover($this->rootDir . '/themes');

assertCount(1, $themes);
assertSame('good-theme', $themes[0]->name);
}

public function testReturnsEmptyListWhenThemesDirectoryIsMissing(): void
{
$themes = (new ProjectThemeDiscovery())->discover($this->rootDir . '/missing');

assertSame([], $themes);
}

public function testRegistersThemesWithoutOverwritingExistingNames(): void
{
mkdir($this->rootDir . '/themes/minimal');
mkdir($this->rootDir . '/themes/fancy');
$registry = new ThemeRegistry();
$registry->register(new Theme('minimal', '/built-in/minimal'));

$registered = (new ProjectThemeDiscovery())->register($registry, $this->rootDir . '/themes');

assertSame(1, $registered);
assertSame('/built-in/minimal', $registry->get('minimal')->path);
assertSame($this->rootDir . '/themes/fancy', $registry->get('fancy')->path);
}

private function removeDir(string $path): void
{
if (!is_dir($path)) {
return;
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}

rmdir($path);
}
}
49 changes: 49 additions & 0 deletions tests/Unit/Console/BuildCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,55 @@ public function testBuildHooksAreDispatched(): void
assertFileExists($outputDir . '/index/index.html');
}

public function testBuildAutoRegistersProjectThemes(): void
{
$tempDir = sys_get_temp_dir() . '/yiipress-build-project-theme-test-' . uniqid();
$contentDir = $tempDir . '/content';
$outputDir = $tempDir . '/output';
$themeDir = $tempDir . '/themes/docs';
mkdir($contentDir, 0o755, true);
mkdir($themeDir, 0o755, true);
$this->tempContentDirs[] = $tempDir;

file_put_contents($contentDir . '/config.yaml', "title: Themed Site\nbase_url: https://example.com\nlanguages: [en]\ntheme: docs\n");
file_put_contents($contentDir . '/index.md', "---\ntitle: Home\npermalink: /\n---\n\nProject theme content.\n");
file_put_contents(
$themeDir . '/entry.php',
<<<'PHP'
<?php
declare(strict_types=1);
?>
<html><body class="docs-theme"><?= $content ?></body></html>
PHP,
);

$themeRegistry = new ThemeRegistry();
$themeRegistry->register(new Theme('minimal', dirname(__DIR__, 3) . '/themes/minimal'));
$templateResolver = new TemplateResolver($themeRegistry);
$pipeline = new ContentProcessorPipeline(new MarkdownProcessor(new MarkdownRenderer()));
$command = new BuildCommand(
rootPath: $tempDir,
contentPipeline: $pipeline,
feedPipeline: new ContentProcessorPipeline(new MarkdownProcessor(new MarkdownRenderer())),
themeRegistry: $themeRegistry,
templateResolver: $templateResolver,
);
$tester = new CommandTester($command);

$exitCode = $tester->execute([
'--content-dir' => $contentDir,
'--output-dir' => $outputDir,
'--workers' => '1',
'--no-cache' => true,
]);

assertSame(0, $exitCode, $tester->getDisplay());
$html = file_get_contents($outputDir . '/index.html');
assertNotFalse($html);
assertStringContainsString('class="docs-theme"', $html);
assertStringContainsString('Project theme content.', $html);
}

public function testBuildReportsInvalidSiteConfigWithoutTrace(): void
{
$tempDir = sys_get_temp_dir() . '/yiipress-build-invalid-config-test-' . uniqid();
Expand Down
Loading