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
76 changes: 76 additions & 0 deletions benchmarks/ThemeAssetCopierBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?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\Theme;
use YiiPress\Build\ThemeAssetCopier;
use YiiPress\Build\ThemeRegistry;

#[BeforeMethods('setUp')]
#[AfterMethods('tearDown')]
final class ThemeAssetCopierBench
{
private string $rootDir;
private ThemeRegistry $registry;
private ThemeAssetCopier $copier;

public function setUp(): void
{
$this->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);
}
}
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<theme>/`, and templates should use `$themeAsset('file.css')` so installed themes do not overwrite each other's files.
34 changes: 22 additions & 12 deletions docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<link rel="stylesheet" href="<?= $h($themeAsset('style.css')) ?>">
<script src="<?= $h($themeAsset('search.js')) ?>" defer></script>
```

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/<theme>/style.css`
- `assets/themes/<theme>/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
<?php

use YiiPress\Build\Asset;
?>
<link rel="stylesheet" href="<?= $h(Asset::url('assets/theme/style.css', $rootPath, $assetManifest)) ?>">
<script src="<?= $h(Asset::url('assets/theme/search.js', $rootPath, $assetManifest)) ?>" defer></script>
<link rel="stylesheet" href="<?= $h(Asset::url('assets/plugins/mermaid.css', $rootPath, $assetManifest)) ?>">
```

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

Expand All @@ -359,14 +369,14 @@ Create a PHP file in `themes/<name>/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;
?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $h($title) ?></title>
<link rel="stylesheet" href="<?= $h(Asset::url('assets/theme/style.css', $rootPath, $assetManifest)) ?>">
<link rel="stylesheet" href="<?= $h($themeAsset('style.css')) ?>">
```

### Variable isolation
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] 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)
Expand Down
13 changes: 13 additions & 0 deletions src/Build/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
8 changes: 8 additions & 0 deletions src/Build/EntryRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use function dirname;
use function filemtime;
use function hash;
use function ltrim;
use function strlen;

final class EntryRenderer
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/Build/PageTemplateRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Closure;

use function ltrim;

final class PageTemplateRenderer
{
/** @var array<string, Closure> */
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions src/Build/TemplateContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Closure;

use function ltrim;

final class TemplateContext
{
/** @var array<string, Closure> */
Expand All @@ -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])) {
Expand All @@ -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);
}
}
12 changes: 12 additions & 0 deletions src/Build/TemplateHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/Build/TemplateResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
Expand Down
Loading
Loading