diff --git a/README.md b/README.md
index 406c044..0a226c7 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ A third-party [Laravel Livewire](https://laravel-livewire.com/) integration for
+ [Static caching](#static-caching)
+ [`@script` and `@assets`](#--script--and---assets-)
+ [Computed Properties](#computed-properties)
+ + [Cascade](#cascade)
+ [Multi-Site / Localization](#multi-site---localization)
+ [Lazy Components](#lazy-components)
+ [Paginating Data](#paginating-data)
@@ -192,6 +193,40 @@ public function entries() {
{{ /entries }}
```
+### Cascade
+Normally all the variables in the Cascade are only available on initial render and get lost between Livewire requests. This means you'd need pass in the required ones into the component yourself.
+To make our lives a bit easier, you can add the `#[Cascade]` attribute to your component.
This is only needed for Antlers views and mirrors the logic of Blade's [`@cascade`](https://statamic.dev/blade#cascade-directive) directive.
+
+```php
+use Livewire\Component;
+use MarcoRieser\Livewire\Attributes\Cascade;
+
+#[Cascade]
+class ShowArticle extends Component
+{
+ ...
+}
+```
+
+Now you can access the variables from the Cascade directly in your Antlers view, even on subsequent renders:
+
+```antlers
+
{{ title }}
+{{ seo_title }}
+```
+
+You can also limit which cascade keys are exposed (and provide defaults):
+
+```php
+#[Cascade([
+ 'title',
+ 'seo_title' => 'Fallback title',
+])]
+class ShowArticle extends Component {}
+```
+
+For subsequent requests, the addon restores the Cascade using the original Livewire URL, so site, request, and content data resolve as expected.
+
### Multi-Site / Localization
By default, your current site is persisted between Livewire requests automatically.
In case you want to implement your own logic, you can disable `localization` in your published `config/statamic-livewire.php` config.
diff --git a/src/Attributes/Cascade.php b/src/Attributes/Cascade.php
new file mode 100644
index 0000000..7e2b0ea
--- /dev/null
+++ b/src/Attributes/Cascade.php
@@ -0,0 +1,40 @@
+toArray();
+ }
+
+ if (! $this->keys) {
+ return $data;
+ }
+
+ return collect($this->keys)
+ ->mapWithKeys(function ($default, $key) use ($data) {
+ if (is_numeric($key)) {
+ $key = $default;
+ $default = null;
+
+ if (! array_key_exists($key, $data)) {
+ throw new CascadeDataNotFoundException($key);
+ }
+ }
+
+ return [$key => Arr::get($data, $key, $default)];
+ })
+ ->all();
+ }
+}
diff --git a/src/Hooks/CascadeVariablesAutoloader.php b/src/Hooks/CascadeVariablesAutoloader.php
new file mode 100644
index 0000000..5ca3a9b
--- /dev/null
+++ b/src/Hooks/CascadeVariablesAutoloader.php
@@ -0,0 +1,44 @@
+isUsingAntlers($view)) {
+ return;
+ }
+
+ /** @var ?CascadeAttribute $attribute */
+ $attribute = $component
+ ->getAttributes()
+ ->whereInstanceOf(CascadeAttribute::class)
+ ->first();
+
+ if (! $attribute) {
+ return;
+ }
+
+ $cascade = $attribute->getCascadeData();
+
+ $view->with(array_merge($cascade, $data));
+ }
+
+ protected function isUsingAntlers(View $view): bool
+ {
+ return $view->getEngine() instanceof AntlersEngine;
+ }
+}
diff --git a/src/Http/Middleware/HydrateCascadeByLivewireUrl.php b/src/Http/Middleware/HydrateCascadeByLivewireUrl.php
new file mode 100644
index 0000000..7d8c09b
--- /dev/null
+++ b/src/Http/Middleware/HydrateCascadeByLivewireUrl.php
@@ -0,0 +1,44 @@
+hydrateSite();
+ $this->hydrateRequest();
+ $this->hydrateContent();
+
+ return $next($request);
+ }
+
+ protected function hydrateSite(): void
+ {
+ Cascade::withSite(Site::current());
+ }
+
+ protected function hydrateRequest(): void
+ {
+ Cascade::withRequest(RequestFacade::create(uri: Livewire::originalUrl(), method: Livewire::originalMethod()));
+ }
+
+ protected function hydrateContent(): void
+ {
+ Cascade::withContent(Data::findByRequestUrl(Livewire::originalUrl()));
+ }
+}
diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php
index 792e1ba..097d638 100644
--- a/src/ServiceProvider.php
+++ b/src/ServiceProvider.php
@@ -6,14 +6,18 @@
use Illuminate\Routing\Router;
use Livewire\Livewire;
use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;
+use MarcoRieser\Livewire\Hooks\CascadeVariablesAutoloader;
use MarcoRieser\Livewire\Hooks\ComputedPropertiesAutoloader;
use MarcoRieser\Livewire\Hooks\SynthesizerAugmentor;
+use MarcoRieser\Livewire\Http\Middleware\HydrateCascadeByLivewireUrl;
use MarcoRieser\Livewire\Http\Middleware\ResolveCurrentSiteByLivewireUrl;
use Statamic\Http\Middleware\Localize;
use Statamic\Providers\AddonServiceProvider;
class ServiceProvider extends AddonServiceProvider
{
+ protected array $middlewares = [];
+
protected $tags = [
'MarcoRieser\Livewire\Tags\Livewire',
];
@@ -24,13 +28,16 @@ public function register(): void
$this->registerSynthesizerAugmentation();
$this->registerComputedPropertiesAutoloader();
+ $this->registerCascadeVariablesAutoloader();
}
public function bootAddon(): void
{
$this->bootLocalization();
+ $this->bootCascadeRestoration();
$this->bootReplacers();
$this->bootSynthesizers();
+ $this->bootMiddlewares();
}
protected function bootLocalization(): void
@@ -39,12 +46,13 @@ protected function bootLocalization(): void
return;
}
- collect($this->app->make(Router::class)->getRoutes()->getRoutes())
- ->filter(fn (Route $route) => $route->named('*livewire.update'))
- ->each(fn (Route $route) => $route->middleware([
- ResolveCurrentSiteByLivewireUrl::class,
- Localize::class,
- ]));
+ $this->middlewares[] = ResolveCurrentSiteByLivewireUrl::class;
+ $this->middlewares[] = Localize::class;
+ }
+
+ protected function bootCascadeRestoration(): void
+ {
+ $this->middlewares[] = HydrateCascadeByLivewireUrl::class;
}
protected function bootReplacers(): void
@@ -75,4 +83,16 @@ protected function registerComputedPropertiesAutoloader(): void
{
Livewire::componentHook(ComputedPropertiesAutoloader::class);
}
+
+ protected function registerCascadeVariablesAutoloader(): void
+ {
+ Livewire::componentHook(CascadeVariablesAutoloader::class);
+ }
+
+ protected function bootMiddlewares(): void
+ {
+ collect($this->app->make(Router::class)->getRoutes()->getRoutes())
+ ->filter(fn (Route $route) => $route->named('*livewire.update'))
+ ->each(fn (Route $route) => $route->middleware($this->middlewares));
+ }
}
diff --git a/tests/Features/CascadeVariables/CascadeVariablesAutoloaderTest.php b/tests/Features/CascadeVariables/CascadeVariablesAutoloaderTest.php
new file mode 100644
index 0000000..b260d24
--- /dev/null
+++ b/tests/Features/CascadeVariables/CascadeVariablesAutoloaderTest.php
@@ -0,0 +1,135 @@
+getAntlersLivewireComponent();
+
+ $testable = Livewire::test($component);
+
+ $testable->assertViewHas('homepage', '/');
+ $testable->assertViewHas('environment', 'testing');
+ }
+
+ #[Test]
+ public function cascade_variables_are_not_autoloaded_in_blade()
+ {
+ $component = $this->getBladeLivewireComponent();
+
+ $testable = Livewire::test($component);
+
+ $testable->assertViewMissing('homepage');
+ $testable->assertViewMissing('environment');
+ }
+
+ #[Test]
+ public function cascade_variables_are_autoloaded_selectively()
+ {
+ $component = $this->getSelectedLivewireComponent();
+
+ $testable = Livewire::test($component);
+
+ $testable->assertViewHas('homepage', '/');
+ $testable->assertViewHas('my_global', true);
+ $testable->assertViewMissing('environment');
+ }
+
+ #[Test]
+ public function cascade_variables_throw_exception_when_invalid()
+ {
+ $this->expectException(ViewException::class);
+ $this->expectExceptionMessage('Cascade data [my_invalid] not found');
+
+ $component = $this->getInvalidLivewireComponent();
+
+ Livewire::test($component);
+ }
+
+ #[Test]
+ public function cascade_variables_are_not_autoloaded_when_attribute_excluded()
+ {
+ $component = $this->getExcludedLivewireComponent();
+
+ $testable = Livewire::test($component);
+
+ $testable->assertViewMissing('homepage');
+ $testable->assertViewMissing('environment');
+ }
+
+ protected function getAntlersLivewireComponent(): Component
+ {
+ return new
+ #[Cascade]
+ class extends Component
+ {
+ public function render()
+ {
+ return view('antlers');
+ }
+ };
+ }
+
+ protected function getBladeLivewireComponent(): Component
+ {
+ return new
+ #[Cascade]
+ class extends Component
+ {
+ public function render()
+ {
+ return view('blade');
+ }
+ };
+ }
+
+ protected function getSelectedLivewireComponent(): Component
+ {
+ return new
+ #[Cascade(['homepage', 'my_global' => true])]
+ class extends Component
+ {
+ public function render()
+ {
+ return view('antlers');
+ }
+ };
+ }
+
+ protected function getInvalidLivewireComponent(): Component
+ {
+ return new
+ #[Cascade(['my_invalid'])]
+ class extends Component
+ {
+ public function render()
+ {
+ return view('antlers');
+ }
+ };
+ }
+
+ protected function getExcludedLivewireComponent(): Component
+ {
+ return new class extends Component
+ {
+ public function render()
+ {
+ return view('antlers');
+ }
+ };
+ }
+}