diff --git a/benchmarks/ProjectThemeDiscoveryBench.php b/benchmarks/ProjectThemeDiscoveryBench.php new file mode 100644 index 0000000..e72d4a7 --- /dev/null +++ b/benchmarks/ProjectThemeDiscoveryBench.php @@ -0,0 +1,71 @@ +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); + } +} diff --git a/docs/engine.md b/docs/engine.md index 76626ee..91610fb 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -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 `/themes//` 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; @@ -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. diff --git a/docs/templates.md b/docs/templates.md index 6f3c8ea..1ca585b 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -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//` are registered automatically, and a project-local `content/templates/` directory is automatically available as the `local` theme. ### Theme resolution order @@ -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 diff --git a/roadmap.md b/roadmap.md index f36c641..05991e4 100644 --- a/roadmap.md +++ b/roadmap.md @@ -64,6 +64,7 @@ ## Priority 4: Templates and theming - [x] Theme system — installable/distributable themes +- [x] Auto-register project themes from `themes//` for binary users - [x] Template partials/includes support - [x] Template helper functions documentation - [x] Multiple layout support (per-entry layout selection via front matter) diff --git a/src/Build/ProjectThemeDiscovery.php b/src/Build/ProjectThemeDiscovery.php new file mode 100644 index 0000000..395a212 --- /dev/null +++ b/src/Build/ProjectThemeDiscovery.php @@ -0,0 +1,66 @@ + + */ + 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; + } +} diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php index 4e1e959..f6fed48 100644 --- a/src/Console/BuildCommand.php +++ b/src/Console/BuildCommand.php @@ -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; @@ -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)); diff --git a/tests/Unit/Build/ProjectThemeDiscoveryTest.php b/tests/Unit/Build/ProjectThemeDiscoveryTest.php new file mode 100644 index 0000000..49065a8 --- /dev/null +++ b/tests/Unit/Build/ProjectThemeDiscoveryTest.php @@ -0,0 +1,100 @@ +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); + } +} diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php index 6670f79..5715866 100644 --- a/tests/Unit/Console/BuildCommandTest.php +++ b/tests/Unit/Console/BuildCommandTest.php @@ -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, + ); + + $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();