From f7d6e11ad10b56c0083d3e084ac4db25c95779dd Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 13:19:34 +0300 Subject: [PATCH] Namespace copied theme assets --- benchmarks/ThemeAssetCopierBench.php | 76 +++++++++++++++++++++ docs/configuration.md | 2 +- docs/engine.md | 2 +- docs/templates.md | 34 +++++---- roadmap.md | 1 + src/Build/Asset.php | 13 ++++ src/Build/EntryRenderer.php | 8 +++ src/Build/PageTemplateRenderer.php | 9 +++ src/Build/TemplateContext.php | 16 +++++ src/Build/TemplateHelpers.php | 12 ++++ src/Build/TemplateResolver.php | 27 ++++++++ src/Build/ThemeAssetCopier.php | 73 +++++++++++++++++++- tests/Unit/Build/EntryRendererTest.php | 4 +- tests/Unit/Build/NotFoundPageWriterTest.php | 14 ++-- tests/Unit/Build/TemplateContextTest.php | 19 ++++++ tests/Unit/Build/TemplateResolverTest.php | 37 ++++++++++ tests/Unit/Build/ThemeAssetCopierTest.php | 53 ++++++++++++-- themes/minimal/partials/footer.php | 6 +- themes/minimal/partials/head.php | 16 ++--- 19 files changed, 381 insertions(+), 41 deletions(-) create mode 100644 benchmarks/ThemeAssetCopierBench.php diff --git a/benchmarks/ThemeAssetCopierBench.php b/benchmarks/ThemeAssetCopierBench.php new file mode 100644 index 0000000..b6dcfa2 --- /dev/null +++ b/benchmarks/ThemeAssetCopierBench.php @@ -0,0 +1,76 @@ +rootDir = sys_get_temp_dir() . '/yiipress-theme-assets-bench-' . uniqid(); + mkdir($this->rootDir . '/output', 0o755, true); + + $this->registry = new ThemeRegistry(); + for ($theme = 1; $theme <= 5; $theme++) { + $themeDir = $this->rootDir . '/theme-' . $theme; + mkdir($themeDir . '/assets/fonts', 0o755, true); + for ($asset = 1; $asset <= 10; $asset++) { + file_put_contents($themeDir . '/assets/asset-' . $asset . '.css', '.a{color:red}'); + file_put_contents($themeDir . '/assets/fonts/font-' . $asset . '.woff2', 'font'); + } + $this->registry->register(new Theme('theme-' . $theme, $themeDir)); + } + + $this->copier = new ThemeAssetCopier(); + } + + public function tearDown(): void + { + $this->removeDir($this->rootDir); + } + + #[Revs(20)] + #[Iterations(3)] + #[Warmup(1)] + public function benchMappings(): void + { + $this->copier->mappings($this->registry); + } + + 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/configuration.md b/docs/configuration.md index bdce7f4..63b3da8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -206,7 +206,7 @@ assets: When enabled, YiiPress renames copied assets to include a content hash, for example: -- `assets/theme/style.css` → `assets/theme/style.4f8d2d5b1c3a.css` +- `assets/themes/minimal/style.css` → `assets/themes/minimal/style.4f8d2d5b1c3a.css` - `blog/assets/hero.png` → `blog/assets/hero.a12b34c56d78.png` Built-in templates use the fingerprinted URLs automatically, and existing hardcoded `src` / `href` diff --git a/docs/engine.md b/docs/engine.md index 76626ee..029b496 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -211,4 +211,4 @@ return [ ]; ``` -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. +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. Theme assets are copied under `assets/themes//`, and templates should use `$themeAsset('file.css')` so installed themes do not overwrite each other's files. diff --git a/docs/templates.md b/docs/templates.md index 6f3c8ea..3929209 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -329,24 +329,34 @@ Partials are reusable template fragments stored in a `partials/` subdirectory of ## Asset helper -Templates and partials should use `Asset::url()` to resolve the final public URL of a copied asset: +Templates and partials should use `$themeAsset()` for files in the active theme's `assets/` directory: + +```php + + +``` + +This is especially useful when `assets.fingerprint: true` is enabled in `content/config.yaml`. +In that mode, `$themeAsset('style.css')` returns the hashed output path rather than the logical one. + +Theme assets are copied to a theme-specific namespace: + +- `assets/themes//style.css` +- `assets/themes//search.js` + +For compatibility, YiiPress also writes `assets/theme/...` aliases for the first registered theme. New templates should use `$themeAsset()` so installed themes cannot overwrite each other's asset files. + +For non-theme assets, use `Asset::url()` with a logical build-relative path: ```php - - + ``` -This is especially useful when `assets.fingerprint: true` is enabled in `content/config.yaml`. -In that mode, `Asset::url('assets/theme/style.css', $rootPath, $assetManifest)` returns the hashed output path rather than the logical one. - -The helper accepts logical build-relative paths such as: - -- `assets/theme/style.css` -- `assets/plugins/mermaid.css` +That helper accepts logical build-relative paths such as `assets/plugins/mermaid.css`. ### Creating a partial @@ -359,14 +369,14 @@ Create a PHP file in `themes//partials/`: * @var string $rootPath * @var AssetFingerprintManifest|null $assetManifest * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $themeAsset */ -use YiiPress\Build\Asset; ?> <?= $h($title) ?> - + ``` ### Variable isolation diff --git a/roadmap.md b/roadmap.md index f36c641..0ebbe66 100644 --- a/roadmap.md +++ b/roadmap.md @@ -64,6 +64,7 @@ ## Priority 4: Templates and theming - [x] Theme system — installable/distributable themes +- [x] Namespaced theme assets for installable themes - [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/Asset.php b/src/Build/Asset.php index d753325..4105427 100644 --- a/src/Build/Asset.php +++ b/src/Build/Asset.php @@ -24,4 +24,17 @@ public static function url( return $resolved; } + + public static function themeUrl( + string $path, + string $themeName, + string $rootPath = '', + ?AssetFingerprintManifest $assetManifest = null, + ): string { + if ($themeName === '') { + return self::url(ThemeAssetCopier::legacyLogicalPath($path), $rootPath, $assetManifest); + } + + return self::url(ThemeAssetCopier::logicalPath($themeName, $path), $rootPath, $assetManifest); + } } diff --git a/src/Build/EntryRenderer.php b/src/Build/EntryRenderer.php index 0e37083..d06a16c 100644 --- a/src/Build/EntryRenderer.php +++ b/src/Build/EntryRenderer.php @@ -24,6 +24,7 @@ use function dirname; use function filemtime; use function hash; +use function ltrim; use function strlen; final class EntryRenderer @@ -222,6 +223,13 @@ private function renderTemplate(SiteConfig $siteConfig, Entry $entry, string $co 'partial' => $this->partialClosures[$themeName], 'rootPath' => $rootPath, 'assetManifest' => $this->assetManifest, + 'themeName' => $themeName, + 'themeAsset' => fn (string $path): string => Asset::themeUrl( + $path, + $this->templateResolver->resolveResourceThemeName('assets/' . ltrim($path, '/'), $themeName), + $rootPath, + $this->assetManifest, + ), 'search' => $siteConfig->search !== null, 'searchResults' => $siteConfig->search?->results ?? 10, ] + $uiViewData->toArray(); diff --git a/src/Build/PageTemplateRenderer.php b/src/Build/PageTemplateRenderer.php index 083bb72..6d77f4b 100644 --- a/src/Build/PageTemplateRenderer.php +++ b/src/Build/PageTemplateRenderer.php @@ -6,6 +6,8 @@ use Closure; +use function ltrim; + final class PageTemplateRenderer { /** @var array */ @@ -51,6 +53,13 @@ public function render(string $templateName, array $variables, string $rootPath) $variables['partial'] = $this->partialClosures[$this->themeName]; $variables['assetManifest'] = $this->assetManifest; + $variables['themeName'] = $this->themeName; + $variables['themeAsset'] = fn (string $path): string => Asset::themeUrl( + $path, + $this->templateResolver->resolveResourceThemeName('assets/' . ltrim($path, '/'), $this->themeName), + $rootPath, + $this->assetManifest, + ); $variables = TemplateHelpers::inject($variables); $html = ($this->templateClosures[$templatePath])($variables); diff --git a/src/Build/TemplateContext.php b/src/Build/TemplateContext.php index bb48b9c..85c9177 100644 --- a/src/Build/TemplateContext.php +++ b/src/Build/TemplateContext.php @@ -6,6 +6,8 @@ use Closure; +use function ltrim; + final class TemplateContext { /** @var array */ @@ -23,9 +25,16 @@ public function __construct( public function partial(string $name, array $variables = []): string { $variables['partial'] = $this->partial(...); + if (!isset($variables['themeName'])) { + $variables['themeName'] = $this->themeName; + } if (!isset($variables['assetManifest'])) { $variables['assetManifest'] = $this->assetManifest; } + if (!isset($variables['themeAsset'])) { + $rootPath = (string) ($variables['rootPath'] ?? ''); + $variables['themeAsset'] = fn (string $path): string => $this->themeAssetUrl($path, $rootPath); + } $variables = TemplateHelpers::inject($variables); if (!isset($this->closureCache[$name])) { @@ -49,4 +58,11 @@ public function rewriteHtml(string $html, string $rootPath = ''): string return new AssetUrlRewriter($this->assetManifest)->rewrite($html, $rootPath); } + + private function themeAssetUrl(string $path, string $rootPath): string + { + $ownerThemeName = $this->templateResolver->resolveResourceThemeName('assets/' . ltrim($path, '/'), $this->themeName); + + return Asset::themeUrl($path, $ownerThemeName, $rootPath, $this->assetManifest); + } } diff --git a/src/Build/TemplateHelpers.php b/src/Build/TemplateHelpers.php index 942d945..ae76040 100644 --- a/src/Build/TemplateHelpers.php +++ b/src/Build/TemplateHelpers.php @@ -25,6 +25,18 @@ public static function inject(array $variables): array $variables['url'] = static fn (string $path): string => UrlResolver::sitePath($path, $rootPath); } + if (!isset($variables['themeAsset'])) { + $themeName = (string) ($variables['themeName'] ?? ''); + $rootPath = (string) ($variables['rootPath'] ?? ''); + $assetManifest = $variables['assetManifest'] ?? null; + $variables['themeAsset'] = static fn (string $path): string => Asset::themeUrl( + $path, + $themeName, + $rootPath, + $assetManifest instanceof AssetFingerprintManifest ? $assetManifest : null, + ); + } + $ui = $variables['ui'] ?? null; if ($ui instanceof UiText && !isset($variables['t'])) { $variables['t'] = static fn (string $key, array $params = []): string => $ui->get($key, $params); diff --git a/src/Build/TemplateResolver.php b/src/Build/TemplateResolver.php index 2281eb3..7828b56 100644 --- a/src/Build/TemplateResolver.php +++ b/src/Build/TemplateResolver.php @@ -75,6 +75,33 @@ public function resolveResource(string $resourcePath, string $themeName = ''): ? return $this->resourceCache[$key] = null; } + public function resolveResourceThemeName(string $resourcePath, string $themeName = ''): string + { + if ($themeName !== '' && $this->themeRegistry->has($themeName)) { + $path = $this->themeRegistry->get($themeName)->path . '/' . $resourcePath; + if (is_file($path)) { + return $themeName; + } + } + + foreach ($this->themeRegistry->all() as $theme) { + if ($theme->name === $themeName) { + continue; + } + $path = $theme->path . '/' . $resourcePath; + if (is_file($path)) { + return $theme->name; + } + } + + return $themeName !== '' ? $themeName : $this->defaultThemeName(); + } + + public function defaultThemeName(): string + { + return $this->themeRegistry->all()[0]->name ?? ''; + } + /** * @return list */ diff --git a/src/Build/ThemeAssetCopier.php b/src/Build/ThemeAssetCopier.php index 8af3747..9afa84a 100644 --- a/src/Build/ThemeAssetCopier.php +++ b/src/Build/ThemeAssetCopier.php @@ -11,7 +11,10 @@ use SplFileInfo; use function dirname; +use function ltrim; +use function preg_replace; use function strlen; +use function trim; final class ThemeAssetCopier { @@ -41,6 +44,24 @@ public function copy(ThemeRegistry $themeRegistry, string $outputDir, ?AssetFing } } + foreach ($this->legacyMappings($themeRegistry) as $sourcePath => $targetRelativePath) { + if ($noWrite) { + $copied++; + continue; + } + + $targetPath = $outputDir . '/' . $targetRelativePath; + $targetDir = dirname($targetPath); + + if (!is_dir($targetDir) && !mkdir($targetDir, 0o755, true) && !is_dir($targetDir)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $targetDir)); + } + + if (FileCopy::copyIfChanged($sourcePath, $targetPath)) { + $copied++; + } + } + return $copied; } @@ -68,10 +89,60 @@ public function mappings(ThemeRegistry $themeRegistry): array } $relativePath = substr($item->getPathname(), strlen($assetsDir) + 1); - $assets[$item->getPathname()] = 'assets/theme/' . $relativePath; + $assets[$item->getPathname()] = self::logicalPath($theme->name, $relativePath); + } + } + + return $assets; + } + + /** + * @return array + */ + public function legacyMappings(ThemeRegistry $themeRegistry): array + { + $theme = $themeRegistry->all()[0] ?? null; + if ($theme === null) { + return []; + } + + $assetsDir = $theme->path . '/assets'; + if (!is_dir($assetsDir)) { + return []; + } + + $assets = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($assetsDir, FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $item) { + /** @var SplFileInfo $item */ + if (!$item->isFile()) { + continue; } + + $relativePath = substr($item->getPathname(), strlen($assetsDir) + 1); + $assets[$item->getPathname()] = self::legacyLogicalPath($relativePath); } return $assets; } + + public static function logicalPath(string $themeName, string $relativePath): string + { + return 'assets/themes/' . self::safeThemeName($themeName) . '/' . ltrim($relativePath, '/'); + } + + public static function legacyLogicalPath(string $relativePath): string + { + return 'assets/theme/' . ltrim($relativePath, '/'); + } + + private static function safeThemeName(string $themeName): string + { + $safeName = trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $themeName), '-'); + + return $safeName !== '' ? $safeName : 'theme'; + } } diff --git a/tests/Unit/Build/EntryRendererTest.php b/tests/Unit/Build/EntryRendererTest.php index e1dd75d..16bb25e 100644 --- a/tests/Unit/Build/EntryRendererTest.php +++ b/tests/Unit/Build/EntryRendererTest.php @@ -486,8 +486,8 @@ public function testRendersSearchUiWhenSearchEnabled(): void assertStringContainsString('id="search-close"', $html); assertStringContainsString('data-ui-attr-aria-label="search_close"', $html); assertStringContainsString('', $html); - assertStringContainsString('assets/theme/search.css', $html); - assertStringContainsString('assets/theme/search.js', $html); + assertStringContainsString('assets/themes/minimal/search.css', $html); + assertStringContainsString('assets/themes/minimal/search.js', $html); } public function testRenderHooksCanObserveAndModifyRenderedHtml(): void diff --git a/tests/Unit/Build/NotFoundPageWriterTest.php b/tests/Unit/Build/NotFoundPageWriterTest.php index 5dfbeda..fd02c7f 100644 --- a/tests/Unit/Build/NotFoundPageWriterTest.php +++ b/tests/Unit/Build/NotFoundPageWriterTest.php @@ -43,14 +43,14 @@ public function testWrites404PageWithRelativeAssetPaths(): void assertFileExists($filePath); $html = (string) file_get_contents($filePath); - assertStringContainsString('href="./assets/theme/style.css"', $html); - assertStringContainsString('src="./assets/theme/dark-mode.js"', $html); - assertStringContainsString('src="./assets/theme/code-copy.js"', $html); - assertStringContainsString('src="./assets/theme/toc-highlight.js"', $html); - assertStringContainsString('src="./assets/theme/ui-language.js"', $html); + assertStringContainsString('href="./assets/themes/minimal/style.css"', $html); + assertStringContainsString('src="./assets/themes/minimal/dark-mode.js"', $html); + assertStringContainsString('src="./assets/themes/minimal/code-copy.js"', $html); + assertStringContainsString('src="./assets/themes/minimal/toc-highlight.js"', $html); + assertStringContainsString('src="./assets/themes/minimal/ui-language.js"', $html); assertStringContainsString('href="./" data-ui-key="go_to_home_page">Go to home page', $html); - assertStringNotContainsString('href="/assets/theme/style.css"', $html); - assertStringNotContainsString('src="/assets/theme/dark-mode.js"', $html); + assertStringNotContainsString('href="/assets/themes/minimal/style.css"', $html); + assertStringNotContainsString('src="/assets/themes/minimal/dark-mode.js"', $html); } private function createTemplateResolver(): TemplateResolver diff --git a/tests/Unit/Build/TemplateContextTest.php b/tests/Unit/Build/TemplateContextTest.php index 3564864..3760c0c 100644 --- a/tests/Unit/Build/TemplateContextTest.php +++ b/tests/Unit/Build/TemplateContextTest.php @@ -107,6 +107,25 @@ public function testStaticAssetHelperReturnsFingerprintedPath(): void assertSame('../../' . $fingerprinted, Asset::url('assets/theme/style.css', '../../', $manifest)); } + public function testThemeAssetHelperReturnsFingerprintedNamespacedThemeAsset(): void + { + mkdir($this->tempDir . '/assets', 0o755, true); + file_put_contents( + $this->tempDir . '/partials/asset.php', + '', + ); + $source = $this->tempDir . '/assets/style.css'; + file_put_contents($source, 'body{}'); + + $manifest = new AssetFingerprintManifest(); + $fingerprinted = $manifest->register('assets/themes/test/style.css', $source); + $context = $this->createContext($manifest); + + $result = $context->partial('asset', ['rootPath' => '../../']); + + assertSame('../../' . $fingerprinted, $result); + } + public function testRewriteHtmlUpdatesReferencedAssets(): void { $source = $this->tempDir . '/style.css'; diff --git a/tests/Unit/Build/TemplateResolverTest.php b/tests/Unit/Build/TemplateResolverTest.php index 78d7923..d3f2930 100644 --- a/tests/Unit/Build/TemplateResolverTest.php +++ b/tests/Unit/Build/TemplateResolverTest.php @@ -83,6 +83,43 @@ public function testTemplateDirsReturnsAllThemePaths(): void assertSame(['/a', '/b'], $resolver->templateDirs()); } + public function testResolveResourceThemeNameReturnsOwningTheme(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-theme-test-' . uniqid(); + mkdir($tempDir . '/assets', 0o755, true); + file_put_contents($tempDir . '/assets/style.css', 'body{}'); + + try { + $registry = new ThemeRegistry(); + $registry->register(new Theme('minimal', dirname(__DIR__, 3) . '/themes/minimal')); + $registry->register(new Theme('custom', $tempDir)); + $resolver = new TemplateResolver($registry); + + assertSame('custom', $resolver->resolveResourceThemeName('assets/style.css', 'custom')); + } finally { + unlink($tempDir . '/assets/style.css'); + rmdir($tempDir . '/assets'); + rmdir($tempDir); + } + } + + public function testResolveResourceThemeNameFallsBackToDefaultTheme(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-theme-test-' . uniqid(); + mkdir($tempDir, 0o755, true); + + try { + $registry = new ThemeRegistry(); + $registry->register(new Theme('minimal', dirname(__DIR__, 3) . '/themes/minimal')); + $registry->register(new Theme('custom', $tempDir)); + $resolver = new TemplateResolver($registry); + + assertSame('minimal', $resolver->resolveResourceThemeName('assets/style.css', 'custom')); + } finally { + rmdir($tempDir); + } + } + private function createResolver(): TemplateResolver { $registry = new ThemeRegistry(); diff --git a/tests/Unit/Build/ThemeAssetCopierTest.php b/tests/Unit/Build/ThemeAssetCopierTest.php index 90714f6..683aefd 100644 --- a/tests/Unit/Build/ThemeAssetCopierTest.php +++ b/tests/Unit/Build/ThemeAssetCopierTest.php @@ -15,6 +15,7 @@ use RecursiveIteratorIterator; use function PHPUnit\Framework\assertFileExists; +use function PHPUnit\Framework\assertFileDoesNotExist; use function PHPUnit\Framework\assertSame; use function PHPUnit\Framework\assertStringContainsString; @@ -44,8 +45,10 @@ public function testCopiesThemeAssetsToOutput(): void $copier = new ThemeAssetCopier(); $copied = $copier->copy($registry, $this->tempDir . '/output'); - assertSame(1, $copied); + assertSame(2, $copied); + assertFileExists($this->tempDir . '/output/assets/themes/test/style.css'); assertFileExists($this->tempDir . '/output/assets/theme/style.css'); + assertStringContainsString('color: red', file_get_contents($this->tempDir . '/output/assets/themes/test/style.css')); assertStringContainsString('color: red', file_get_contents($this->tempDir . '/output/assets/theme/style.css')); } @@ -72,12 +75,34 @@ public function testCopiesNestedAssets(): void $copier = new ThemeAssetCopier(); $copied = $copier->copy($registry, $this->tempDir . '/output'); - assertSame(2, $copied); + assertSame(4, $copied); + assertFileExists($this->tempDir . '/output/assets/themes/test/style.css'); + assertFileExists($this->tempDir . '/output/assets/themes/test/fonts/mono.woff2'); assertFileExists($this->tempDir . '/output/assets/theme/style.css'); assertFileExists($this->tempDir . '/output/assets/theme/fonts/mono.woff2'); } - public function testCopiesFingerprintedThemeAssetsWhenManifestProvided(): void + public function testCopiesThemeAssetsWithoutCrossThemeCollisions(): void + { + mkdir($this->tempDir . '/other/assets', 0o755, true); + file_put_contents($this->tempDir . '/theme/assets/style.css', 'body { color: red; }'); + file_put_contents($this->tempDir . '/other/assets/style.css', 'body { color: blue; }'); + + $registry = new ThemeRegistry(); + $registry->register(new Theme('test', $this->tempDir . '/theme')); + $registry->register(new Theme('other', $this->tempDir . '/other')); + + $copier = new ThemeAssetCopier(); + $copied = $copier->copy($registry, $this->tempDir . '/output'); + + assertSame(3, $copied); + assertStringContainsString('color: red', file_get_contents($this->tempDir . '/output/assets/themes/test/style.css')); + assertStringContainsString('color: blue', file_get_contents($this->tempDir . '/output/assets/themes/other/style.css')); + assertStringContainsString('color: red', file_get_contents($this->tempDir . '/output/assets/theme/style.css')); + assertFileDoesNotExist($this->tempDir . '/output/assets/theme/other/style.css'); + } + + public function testCopiesFingerprintedCanonicalThemeAssetsWhenManifestProvided(): void { $source = $this->tempDir . '/theme/assets/style.css'; file_put_contents($source, 'body { color: red; }'); @@ -86,13 +111,31 @@ public function testCopiesFingerprintedThemeAssetsWhenManifestProvided(): void $registry->register(new Theme('test', $this->tempDir . '/theme')); $manifest = new AssetFingerprintManifest(); - $resolved = $manifest->register('assets/theme/style.css', $source); + $resolved = $manifest->register('assets/themes/test/style.css', $source); $copier = new ThemeAssetCopier(); $copied = $copier->copy($registry, $this->tempDir . '/output', $manifest); - assertSame(1, $copied); + assertSame(2, $copied); assertFileExists($this->tempDir . '/output/' . $resolved); + assertFileExists($this->tempDir . '/output/assets/theme/style.css'); + } + + public function testMappingsUseNamespacedLogicalPaths(): void + { + file_put_contents($this->tempDir . '/theme/assets/style.css', 'body {}'); + + $registry = new ThemeRegistry(); + $registry->register(new Theme('test', $this->tempDir . '/theme')); + + $mappings = (new ThemeAssetCopier())->mappings($registry); + + assertSame('assets/themes/test/style.css', $mappings[$this->tempDir . '/theme/assets/style.css']); + } + + public function testLogicalPathSanitizesThemeName(): void + { + assertSame('assets/themes/bad-theme/style.css', ThemeAssetCopier::logicalPath('../bad theme', 'style.css')); } private function removeDir(string $dir): void diff --git a/themes/minimal/partials/footer.php b/themes/minimal/partials/footer.php index bd18640..2460ee5 100644 --- a/themes/minimal/partials/footer.php +++ b/themes/minimal/partials/footer.php @@ -2,15 +2,13 @@ use YiiPress\Content\Model\Navigation; use YiiPress\Render\NavigationRenderer; -use YiiPress\Build\AssetFingerprintManifest; -use YiiPress\Build\Asset; /** * @var ?Navigation $nav * @var string $rootPath - * @var AssetFingerprintManifest|null $assetManifest * @var string $uiLanguage * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $themeAsset */ $uiLanguage ??= 'en'; ?> @@ -30,4 +28,4 @@ - + diff --git a/themes/minimal/partials/head.php b/themes/minimal/partials/head.php index d13b97e..b70da4e 100644 --- a/themes/minimal/partials/head.php +++ b/themes/minimal/partials/head.php @@ -14,11 +14,11 @@ * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h * @var Closure(string): string $url + * @var Closure(string): string $themeAsset * @var Closure(string, array): string $t */ use YiiPress\Build\AssetFingerprintManifest; -use YiiPress\Build\Asset; use YiiPress\Build\MetaTags; $headAssets ??= ''; @@ -178,17 +178,17 @@ })(); - + - - - - + + + + - - + +