Skip to content

Commit 99f2d7b

Browse files
committed
fix: keep the site up when a Vite build is missing
Laravel's Vite throws on a missing manifest, and blocks resolve their scripts while WordPress boots: one unbuilt theme answered 500 on every request, wp-admin and wp-login.php included. A container with neither a hot file nor a manifest now gives no asset URLs; the missing build is logged once per request and named in an admin notice. Fixes Pollora/pollora#79
1 parent 75e7db1 commit 99f2d7b

6 files changed

Lines changed: 250 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- A page template declared in the theme's `config/templates.php` is rendered by its Blade view: WordPress puts the template slug first in the page hierarchy without extension (`landing`), which Pollora looked up as a file named `landing` instead of `landing.blade.php`, so `page.blade.php` answered (pollora#159)
1212
- Before the database is configured, the WordPress fallback route answers 503 instead of 500 (`Call to undefined function wp_using_themes()`): with `APP_DEBUG`, a "Pollora is not set up yet" page names the missing settings (`DB_CONNECTION=mysql`, `DB_HOST`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`) and the commands to run (`pollora:env:setup`, `pollora:install`, `pollora:doctor`); without it, the application's or Laravel's 503 page. Laravel routes keep working (pollora#78)
1313
- `composer create-project pollora/pollora` without a database no longer ends with a failing script ("Database name is required"): Composer runs `pollora:env:setup` without a terminal, which the command now treats as non-interactive, printing the settings to fill and the commands to run (pollora#78)
14+
- A theme, plugin or module whose Vite build is missing (npm absent, a failed build, `public/build` deleted) no longer takes the whole site down: every request, `wp-login.php` and wp-admin included, answered 500 `Vite manifest not found` because blocks resolve their scripts while WordPress boots. A container with neither a hot file nor a manifest now gives no asset URLs (blocks register without scripts, `Asset::url()` gives an empty string), the missing build is logged once per request and an admin notice names the manifest and the commands to run (`npm run build`, `pollora:doctor`) (pollora#79)
1415

1516
## [v13.35.4](https://github.com/Pollora/framework/compare/v13.35.3...v13.35.4) - 2026-10-09
1617

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pollora\Asset\Application\Services;
6+
7+
use Illuminate\Support\Facades\Log;
8+
9+
/**
10+
* The asset containers asked for assets this request whose Vite build is missing.
11+
*
12+
* A theme, plugin or module that was never built (npm missing, a failed
13+
* build, `public/build` deleted) has neither a hot file nor a manifest. Its
14+
* assets then resolve to nothing instead of throwing, and the site stays
15+
* reachable: each missing build is logged once per request and listed in
16+
* wp-admin by `MissingBuildNotice`.
17+
*/
18+
class MissingBuilds
19+
{
20+
/**
21+
* Manifest path of each container whose build is missing, by container name.
22+
*
23+
* @var array<string, string>
24+
*/
25+
private array $missing = [];
26+
27+
public function record(string $container, string $manifest): void
28+
{
29+
if (isset($this->missing[$container])) {
30+
return;
31+
}
32+
33+
$this->missing[$container] = $manifest;
34+
35+
Log::warning(sprintf('Pollora: the "%s" assets are not built, no Vite manifest at %s. Its scripts and styles are left out until you run npm run build.', $container, $manifest), [
36+
'container' => $container,
37+
'manifest' => $manifest,
38+
]);
39+
}
40+
41+
/**
42+
* @return array<string, string> Manifest path by container name
43+
*/
44+
public function all(): array
45+
{
46+
return $this->missing;
47+
}
48+
}

‎src/Asset/Infrastructure/Providers/AssetServiceProvider.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
use Illuminate\Support\ServiceProvider;
99
use Pollora\Asset\Application\Services\AssetManager;
1010
use Pollora\Asset\Application\Services\AssetRetrievalService;
11+
use Pollora\Asset\Application\Services\MissingBuilds;
1112
use Pollora\Asset\Domain\Contracts\AssetRepositoryInterface;
1213
use Pollora\Asset\Infrastructure\Repositories\InMemoryAssetRepository;
1314
use Pollora\Asset\Infrastructure\Services\AssetEnqueuer;
1415
use Pollora\Asset\Infrastructure\Services\RootAssetManager;
16+
use Pollora\Asset\UI\Http\MissingBuildNotice;
17+
use Pollora\Hook\Domain\Contract\Action;
1518

1619
/**
1720
* Laravel service provider for asset management services and bindings.
@@ -33,6 +36,7 @@ public function register(): void
3336
$app->make(AssetRetrievalService::class)
3437
));
3538
$this->app->bind(AssetEnqueuer::class, fn (Application $app): AssetEnqueuer => new AssetEnqueuer($app));
39+
$this->app->singleton(MissingBuilds::class);
3640
$this->app->singleton(RootAssetManager::class, fn (Application $app): RootAssetManager => new RootAssetManager(
3741
$app->make(AssetManager::class)
3842
));
@@ -44,5 +48,7 @@ public function register(): void
4448
public function boot(): void
4549
{
4650
$this->app->make(RootAssetManager::class)->registerRootAssets();
51+
52+
$this->app->make(Action::class)->add('admin_notices', [$this->app->make(MissingBuildNotice::class), 'render']);
4753
}
4854
}

‎src/Asset/Infrastructure/Services/ViteManager.php‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Foundation\Vite;
88
use Illuminate\Support\Facades\Vite as ViteFacade;
9+
use Pollora\Asset\Application\Services\MissingBuilds;
910
use Pollora\Asset\Domain\Contracts\ViteManagerInterface;
1011
use Pollora\Asset\Domain\Exceptions\AssetException;
1112
use Pollora\Asset\Infrastructure\Repositories\AssetContainer;
@@ -53,13 +54,7 @@ public function container(): AssetContainer
5354
/**
5455
* Gets the URLs for the specified entry points.
5556
*
56-
* @param array $entrypoints List of entry points to process
57-
* @return array Array of asset URLs grouped by type (js/css)
58-
*
59-
* @throws AssetException When entrypoints array is empty
60-
*/
61-
/**
62-
* Gets the URLs for the specified entry points.
57+
* Without a build, there are none: see isMissingBuild().
6358
*
6459
* @param array $entrypoints List of entry points to process
6560
* @return array Array of asset URLs grouped by type (js/css)
@@ -72,6 +67,10 @@ public function getAssetUrls(array $entrypoints): array
7267
throw new AssetException('Entry points array cannot be empty.');
7368
}
7469

70+
if ($this->isMissingBuild()) {
71+
return ['js' => [], 'css' => []];
72+
}
73+
7574
$basePath = $this->container()->getBasePath();
7675

7776
// If basePath is empty or null, no additional processing is needed
@@ -91,14 +90,46 @@ public function getAssetUrls(array $entrypoints): array
9190
/**
9291
* Gets the URL for a specific asset path.
9392
*
93+
* Without a build, an empty string: see isMissingBuild().
94+
*
9495
* @param string $path The asset path
9596
* @return string The complete asset URL
9697
*/
9798
public function asset(string $path): string
9899
{
100+
if ($this->isMissingBuild()) {
101+
return '';
102+
}
103+
99104
return $this->getViteInstance()->asset($this->container()->getBasePath().$path);
100105
}
101106

107+
/**
108+
* Whether the container has neither a running dev server nor a manifest.
109+
*
110+
* Laravel's Vite throws on a missing manifest, and assets are resolved
111+
* while WordPress boots (blocks register on `init`): one unbuilt theme
112+
* used to answer 500 everywhere, wp-admin and wp-login.php included
113+
* (Pollora/pollora#79). The missing build is recorded instead, logged
114+
* once and named in wp-admin.
115+
*/
116+
public function isMissingBuild(): bool
117+
{
118+
if ($this->isRunningHot()) {
119+
return false;
120+
}
121+
122+
$manifest = public_path($this->container->getBuildDirectory().'/'.$this->container->getManifestPath());
123+
124+
if (is_file($manifest)) {
125+
return false;
126+
}
127+
128+
resolve(MissingBuilds::class)->record($this->container->getName(), $manifest);
129+
130+
return true;
131+
}
132+
102133
/**
103134
* The URL of the Vite client on the dev server, or an empty string when Vite is not running hot.
104135
*/
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pollora\Asset\UI\Http;
6+
7+
use Pollora\Asset\Application\Services\MissingBuilds;
8+
9+
/**
10+
* Says in wp-admin which Vite builds are missing, and how to build them.
11+
*
12+
* Without a build the site renders unstyled and its blocks have no editor
13+
* scripts, with nothing else to say why.
14+
*/
15+
final readonly class MissingBuildNotice
16+
{
17+
public function __construct(private MissingBuilds $missingBuilds) {}
18+
19+
public function render(): void
20+
{
21+
$missing = $this->missingBuilds->all();
22+
23+
if ($missing === [] || ! current_user_can('manage_options')) {
24+
return;
25+
}
26+
27+
$ddev = getenv('IS_DDEV_PROJECT') === 'true';
28+
$build = $ddev ? 'ddev npm install && ddev npm run build' : 'npm install && npm run build';
29+
$doctor = ($ddev ? 'ddev exec ' : '').'php artisan pollora:doctor';
30+
31+
// A theme's blocks share its build: one line per manifest
32+
$containersByManifest = [];
33+
foreach ($missing as $container => $manifest) {
34+
$containersByManifest[$manifest][] = $container;
35+
}
36+
37+
$items = '';
38+
foreach ($containersByManifest as $manifest => $containers) {
39+
$items .= sprintf(
40+
'<li><code>%s</code> — %s <code>%s</code></li>',
41+
esc_html(implode(', ', $containers)),
42+
esc_html__('no manifest at', 'pollora'),
43+
esc_html($this->relative($manifest))
44+
);
45+
}
46+
47+
printf(
48+
'<div class="notice notice-warning"><p><strong>%s</strong></p><p>%s</p><ul>%s</ul><p>%s<br><code>%s</code></p><p>%s<br><code>%s</code></p></div>',
49+
esc_html__('Pollora: assets not built', 'pollora'),
50+
esc_html__('These Vite builds are missing, so their scripts and styles are left out: the site renders unstyled and blocks have no editor scripts.', 'pollora'),
51+
$items,
52+
esc_html__('Build them from the folder of the theme, plugin or module:', 'pollora'),
53+
esc_html($build),
54+
esc_html__('Not sure which folder? The builds check names it:', 'pollora'),
55+
esc_html($doctor)
56+
);
57+
}
58+
59+
private function relative(string $path): string
60+
{
61+
$base = rtrim(base_path(), '/').'/';
62+
63+
return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
64+
}
65+
}

‎tests/Feature/Asset/ViteManagerTest.php‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
declare(strict_types=1);
44

5+
use Brain\Monkey\Functions;
56
use Illuminate\Filesystem\Filesystem;
7+
use Illuminate\Support\Facades\Log;
8+
use Pollora\Asset\Application\Services\MissingBuilds;
69
use Pollora\Asset\Infrastructure\Repositories\AssetContainer;
710
use Pollora\Asset\Infrastructure\Services\ViteManager;
11+
use Pollora\Asset\UI\Http\MissingBuildNotice;
812

913
beforeEach(function (): void {
14+
// AssetServiceProvider's binding: one record of missing builds per request
15+
app()->singleton(MissingBuilds::class);
16+
1017
$this->buildRoot = 'build/vite-manager-test-'.uniqid();
1118
$this->hotFile = sys_get_temp_dir().'/vite-manager-test-'.uniqid().'.hot';
1219

@@ -69,3 +76,88 @@
6976
expect($theme->getViteClientHtml())->toContain('https://example.test:5173/@vite/client');
7077
});
7178
});
79+
80+
/**
81+
* A container with neither a dev server nor a manifest: the build was never
82+
* run, failed, or public/build was deleted. Laravel's Vite threw while
83+
* WordPress booted and every request answered 500 (Pollora/pollora#79).
84+
*/
85+
describe('ViteManager without a build', function (): void {
86+
beforeEach(function (): void {
87+
$this->unbuilt = new ViteManager(new AssetContainer('theme', [
88+
'hot_file' => sys_get_temp_dir().'/missing-'.uniqid().'.hot',
89+
'build_directory' => $this->buildRoot.'/theme',
90+
'manifest_path' => 'manifest.json',
91+
'base_path' => 'resources/assets/',
92+
]));
93+
});
94+
95+
it('gives no asset URLs instead of throwing', function (): void {
96+
expect($this->unbuilt->getAssetUrls(['app.js']))->toBe(['js' => [], 'css' => []])
97+
->and($this->unbuilt->asset('images/logo.svg'))->toBe('')
98+
->and($this->unbuilt->isMissingBuild())->toBeTrue();
99+
});
100+
101+
it('records the missing build once, with its manifest', function (): void {
102+
Log::spy();
103+
104+
$this->unbuilt->getAssetUrls(['app.js']);
105+
$this->unbuilt->getAssetUrls(['editor.js']);
106+
$this->unbuilt->asset('images/logo.svg');
107+
108+
expect(resolve(MissingBuilds::class)->all())->toBe([
109+
'theme' => public_path($this->buildRoot.'/theme/manifest.json'),
110+
]);
111+
112+
Log::shouldHaveReceived('warning')->once();
113+
});
114+
115+
it('records nothing for a built container or one served by the dev server', function (): void {
116+
$built = new ViteManager(new AssetContainer('plugin.demo', [
117+
'hot_file' => sys_get_temp_dir().'/missing-'.uniqid().'.hot',
118+
'build_directory' => $this->buildRoot.'/plugin',
119+
'manifest_path' => 'manifest.json',
120+
]));
121+
$hot = new ViteManager(new AssetContainer('theme.hot', [
122+
'hot_file' => $this->hotFile,
123+
'build_directory' => $this->buildRoot.'/missing',
124+
'manifest_path' => 'manifest.json',
125+
]));
126+
127+
expect($built->isMissingBuild())->toBeFalse()
128+
->and($hot->isMissingBuild())->toBeFalse()
129+
->and(resolve(MissingBuilds::class)->all())->toBe([]);
130+
});
131+
});
132+
133+
describe('MissingBuildNotice', function (): void {
134+
beforeEach(function (): void {
135+
Functions\when('current_user_can')->justReturn(true);
136+
Functions\when('esc_html')->returnArg();
137+
Functions\when('esc_html__')->returnArg();
138+
});
139+
140+
it('names the missing build and the commands to run', function (): void {
141+
resolve(MissingBuilds::class)->record('theme', base_path('public/build/theme/default/manifest.json'));
142+
resolve(MissingBuilds::class)->record('theme.blocks', base_path('public/build/theme/default/manifest.json'));
143+
144+
ob_start();
145+
resolve(MissingBuildNotice::class)->render();
146+
$notice = (string) ob_get_clean();
147+
148+
expect($notice)
149+
->toContain('Pollora: assets not built')
150+
->toContain('theme, theme.blocks')
151+
->and(substr_count($notice, 'public/build/theme/default/manifest.json'))->toBe(1)
152+
->and($notice)
153+
->toContain('npm install && npm run build')
154+
->toContain('php artisan pollora:doctor');
155+
});
156+
157+
it('stays silent when every build is there', function (): void {
158+
ob_start();
159+
resolve(MissingBuildNotice::class)->render();
160+
161+
expect(ob_get_clean())->toBe('');
162+
});
163+
});

0 commit comments

Comments
 (0)