diff --git a/app/.env.example b/app/.env.example
index 3bcabcec..b7817722 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -1,4 +1,4 @@
-APP_NAME=Laravel
+APP_NAME="Laravel Splade"
APP_ENV=local
APP_KEY=base64:TBVjDAO1zLKHWGrb6ZI3JWSoJgUWkB8Wf6GfQdes4h8=
APP_DEBUG=true
diff --git a/app/app/Http/Controllers/ModalController.php b/app/app/Http/Controllers/ModalController.php
new file mode 100644
index 00000000..c5167eb1
--- /dev/null
+++ b/app/app/Http/Controllers/ModalController.php
@@ -0,0 +1,39 @@
+description('First Navigation')
+ ->keywords('een, one');
+
+ return view('navigation.one');
+ }
+
+ public function two()
+ {
+ SEO::title('Navigation Two')
+ ->description('Second Navigation')
+ ->keywords(['twee', 'two']);
+
+ return view('navigation.two');
+ }
+
+ public function three()
+ {
+ return view('navigation.three');
+ }
+
+ public function form()
+ {
+ return view('navigation.form');
+ }
+}
diff --git a/app/resources/views/root.blade.php b/app/resources/views/root.blade.php
index adea921a..6c82cb36 100644
--- a/app/resources/views/root.blade.php
+++ b/app/resources/views/root.blade.php
@@ -4,11 +4,10 @@
-
Laravel
-
+ @spladeHead
@vite('resources/js/app.js')
diff --git a/app/routes/web.php b/app/routes/web.php
index 52302679..a8f32017 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -6,6 +6,8 @@
use App\Events\ToastEvent;
use App\Http\Controllers\BackFormController;
use App\Http\Controllers\FileFormController;
+use App\Http\Controllers\ModalController;
+use App\Http\Controllers\NavigationController;
use App\Http\Controllers\SimpleFormController;
use App\Http\Controllers\SlowFormController;
use App\Http\Controllers\ToastController;
@@ -69,18 +71,19 @@
Route::view('form/jsonable', 'form.jsonable')->name('form.jsonable');
Route::view('form/jsonSerializable', 'form.jsonSerializable')->name('form.jsonSerializable');
- Route::view('navigation/one', 'navigation.one')->name('navigation.one');
- Route::view('navigation/two', 'navigation.two')->name('navigation.two');
- Route::view('navigation/three', 'navigation.three')->name('navigation.three');
- Route::view('navigation/form', 'navigation.form')->name('navigation.form');
+ Route::get('navigation/one', [NavigationController::class, 'one'])->name('navigation.one');
+ Route::get('navigation/two', [NavigationController::class, 'two'])->name('navigation.two');
+ Route::get('navigation/three', [NavigationController::class, 'three'])->name('navigation.three');
+ Route::get('navigation/form', [NavigationController::class, 'form'])->name('navigation.form');
+
Route::get('navigation/notFound', fn () => abort(404))->name('navigation.notFound');
Route::get('navigation/serverError', fn () => throw new Exception('Whoops!'))->name('navigation.serverError');
- Route::view('modal/base', 'modal.base')->name('modal.base');
- Route::view('modal/one', 'modal.one')->name('modal.one');
- Route::view('modal/two', 'modal.two')->name('modal.two');
- Route::view('modal/slideover', 'modal.slideover')->name('modal.slideover');
- Route::view('modal/validation', 'modal.validation')->name('modal.validation');
+ Route::get('modal/base', [ModalController::class, 'base'])->name('modal.base');
+ Route::get('modal/one', [ModalController::class, 'one'])->name('modal.one');
+ Route::get('modal/two', [ModalController::class, 'two'])->name('modal.two');
+ Route::get('modal/slideover', [ModalController::class, 'slideover'])->name('modal.slideover');
+ Route::get('modal/validation', [ModalController::class, 'validation'])->name('modal.validation');
Route::post('state', function () {
Splade::share('info', 'This is invalid');
diff --git a/app/tests/Browser.php b/app/tests/Browser.php
new file mode 100644
index 00000000..97ca8754
--- /dev/null
+++ b/app/tests/Browser.php
@@ -0,0 +1,22 @@
+driver->executeScript('return document.querySelector("meta[name=\"' . $name . '\"]")?.getAttribute("content")');
+
+ PHPUnit::assertEquals(
+ $content,
+ $driverContent,
+ "Meta with name [{$name}] expected content [{$content}] does not equal actual title [{$driverContent}]."
+ );
+
+ return $this;
+ }
+}
diff --git a/app/tests/Browser/HeadTest.php b/app/tests/Browser/HeadTest.php
new file mode 100644
index 00000000..d0ff176b
--- /dev/null
+++ b/app/tests/Browser/HeadTest.php
@@ -0,0 +1,58 @@
+browse(function (Browser $browser) {
+ $browser->visit('/navigation/one')
+ ->waitForText('NavigationOne')
+ ->assertTitle('Navigation One')
+ ->assertMetaByName('description', 'First Navigation')
+ ->assertMetaByName('keywords', 'een, one')
+ ->click('@two')
+ ->waitForText('NavigationTwo')
+ ->assertTitle('Navigation Two')
+ ->assertMetaByName('description', 'Second Navigation')
+ ->assertMetaByName('keywords', 'twee, two')
+ ->click('@three')
+ ->waitForText('NavigationThree')
+
+ // defaults:
+ ->assertTitle('Laravel Splade')
+ ->assertMetaByName('description', 'Splade provides a super easy way to build Single Page Applications (SPA) using standard Laravel Blade templates, enhanced with renderless Vue 3 components.')
+ ->assertMetaByName('keywords', 'Laravel, Splade');
+ });
+ }
+
+ /** @test */
+ public function it_updates_the_head_when_modals_are_opened_and_closed()
+ {
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/modal/base')
+ ->waitForText('ModalComponent')
+ ->assertTitle('Modal Base')
+ ->click('@one')
+ ->waitForText('ModalComponentOne')
+ ->pause(500)
+ ->assertTitle('Modal One')
+ ->click('@two')
+ ->waitForText('ModalComponentTwo')
+ ->pause(500)
+ ->assertTitle('Modal Two')
+ ->click('@close-two')
+ ->waitForText('ModalComponentOne')
+ ->pause(500)
+ ->assertTitle('Modal One')
+ ->click('@close-one')
+ ->pause(500)
+ ->assertTitle('Modal Base');
+ });
+ }
+}
diff --git a/app/tests/DuskTestCase.php b/app/tests/DuskTestCase.php
index 50c8afae..fb583b7b 100644
--- a/app/tests/DuskTestCase.php
+++ b/app/tests/DuskTestCase.php
@@ -6,7 +6,6 @@
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Arr;
-use Laravel\Dusk\Browser;
use Laravel\Dusk\TestCase as BaseTestCase;
use Spatie\Snapshots\MatchesSnapshots;
@@ -15,6 +14,17 @@ abstract class DuskTestCase extends BaseTestCase
use CreatesApplication;
use MatchesSnapshots;
+ /**
+ * Create a new Browser instance.
+ *
+ * @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver
+ * @return \Tests\Browser
+ */
+ protected function newBrowser($driver)
+ {
+ return new Browser($driver);
+ }
+
/**
* Prepare for Dusk test execution.
*
diff --git a/app/tests/Feature/HeadTest.php b/app/tests/Feature/HeadTest.php
new file mode 100644
index 00000000..c83ab463
--- /dev/null
+++ b/app/tests/Feature/HeadTest.php
@@ -0,0 +1,17 @@
+get('/navigation/one')
+ ->assertSee('Navigation One', false)
+ ->assertSee('', false)
+ ->assertSee('', false);
+ }
+}
diff --git a/app/tests/Feature/SsrTest.php b/app/tests/Feature/SsrTest.php
index fe23aee8..d6261826 100644
--- a/app/tests/Feature/SsrTest.php
+++ b/app/tests/Feature/SsrTest.php
@@ -24,9 +24,9 @@ public function it_has_a_server_that_handles_ssr_requests()
$this->assertArrayHasKey('body', $data);
// evaluated form
- $this->assertStringContainsString('', $data['body']);
+ $this->assertStringContainsString('', $data['body'] ?? '');
// rendered components
- $this->assertStringContainsString('grid grid-cols-3 grid-flow-row-3', $data['body']);
+ $this->assertStringContainsString('grid grid-cols-3 grid-flow-row-3', $data['body'] ?? '');
}
}
diff --git a/composer.json b/composer.json
index ffdd2a9f..5f8c70e1 100644
--- a/composer.json
+++ b/composer.json
@@ -23,9 +23,6 @@
"laravel/pint": "^1.0",
"nunomaduro/collision": "^6.0",
"nunomaduro/larastan": "^2.0.1",
- "orchestra/testbench": "^7.0",
- "pestphp/pest": "^1.21",
- "pestphp/pest-plugin-laravel": "^1.1",
"phpunit/phpunit": "^9.5"
},
"autoload": {
@@ -57,8 +54,9 @@
"ProtoneMedia\\Splade\\ServiceProvider"
],
"aliases": {
- "Toast": "ProtoneMedia\\Splade\\Facades\\Toast",
- "Splade": "ProtoneMedia\\Splade\\Facades\\Splade"
+ "SEO": "ProtoneMedia\\Splade\\Facades\\SEO",
+ "Splade": "ProtoneMedia\\Splade\\Facades\\Splade",
+ "Toast": "ProtoneMedia\\Splade\\Facades\\Toast"
}
}
},
diff --git a/config/splade.php b/config/splade.php
index ad22868f..8f9086a9 100644
--- a/config/splade.php
+++ b/config/splade.php
@@ -9,6 +9,24 @@
'component_prefix' => 'splade',
],
+ 'seo' => [
+
+ 'title_prefix' => '',
+
+ 'title_suffix' => '',
+
+ 'defaults' => [
+
+ 'title' => env('APP_NAME', 'Laravel Splade'),
+
+ 'description' => 'Splade provides a super easy way to build Single Page Applications (SPA) using standard Laravel Blade templates, enhanced with renderless Vue 3 components.',
+
+ 'keywords' => ['Laravel', 'Splade'],
+
+ ],
+
+ ],
+
'ssr' => [
'enabled' => env('SPLADE_SSR_ENABLED', false),
diff --git a/lib/Splade.js b/lib/Splade.js
index 5ffffe21..55e5f272 100644
--- a/lib/Splade.js
+++ b/lib/Splade.js
@@ -14,12 +14,14 @@ function init(initialHtml, initialSpladeData) {
setSpladeData(initialSpladeData);
+ onHead(initialSpladeData.head);
onHtml(initialHtml);
const href = isSsr ? "" : location.href;
const newPage = setCurrentPage(
href,
+ initialSpladeData.head,
initialHtml,
{},
pageVisitId.value
@@ -33,12 +35,14 @@ function handlePopstateEvent($event) {
stack.value = 0;
+ onHead(currentPage.value.head);
onHtml(currentPage.value.html, currentPage.value.rememberedState.scrollY);
}
-function setCurrentPage(url, html, rememberedState, pageVisitId) {
+function setCurrentPage(url, head, html, rememberedState, pageVisitId) {
const newPage = {
url,
+ head,
html,
rememberedState,
pageVisitId,
@@ -71,6 +75,7 @@ function newPageFromResponse(response) {
}
setSpladeData(response.data.splade);
+ onHead(response.data.splade.head);
if (response.data.splade.modal) {
return onModal(response.data.html, response.data.splade.modal);
@@ -81,11 +86,13 @@ function newPageFromResponse(response) {
) {
stack.value = 0; // reset modals
pageVisitId.value++; // mark as next page visit
+
onHtml(response.data.html, 0); // suppy html to app
}
const newPage = setCurrentPage(
url,
+ response.data.splade.head,
response.data.html,
currentPage.value.rememberedState
? { ...currentPage.value.rememberedState }
@@ -102,6 +109,8 @@ const stack = ref(0);
function popStack() {
stack.value--;
+
+ onHead(headData(stack.value));
}
//
@@ -122,6 +131,14 @@ const hasValidationErrors = (stack) => {
//
+const _headData = ref({});
+
+const headData = (stack) => {
+ return _headData.value[stack];
+};
+
+//
+
const _flashData = ref({});
const flashData = (stack) => {
@@ -181,6 +198,8 @@ function setSpladeData(spladeData) {
_flashData.value[stack.value] = spladeData.flash ? spladeData.flash : {};
+ _headData.value[stack.value] = spladeData.head ? spladeData.head : {};
+
forEach(spladeData.toasts ? spladeData.toasts : [], (toast) => {
toasts.value.push(toast);
});
@@ -196,6 +215,11 @@ function onServerError(html) {
onServerErrorFunction.value(html);
}
+function onHead(head) {
+
+ onHeadFunction.value(head);
+}
+
function onHtml(html, scrollY) {
onHtmlFunction.value(html, scrollY);
}
@@ -296,6 +320,7 @@ function refresh() {
request(currentPage.value.url, "GET", {}, { "X-Splade-Refresh": true });
}
+const onHeadFunction = ref(() => { });
const onHtmlFunction = ref(() => { });
const onModalFunction = ref(() => { });
const onServerErrorFunction = ref(() => { });
@@ -307,6 +332,9 @@ const Splade = {
slideover,
refresh,
request,
+ setOnHead(onHead) {
+ onHeadFunction.value = onHead;
+ },
setOnHtml(onHtml) {
onHtmlFunction.value = onHtml;
},
diff --git a/lib/SpladeApp.vue b/lib/SpladeApp.vue
index 68e6abca..8f6e3beb 100644
--- a/lib/SpladeApp.vue
+++ b/lib/SpladeApp.vue
@@ -37,9 +37,10 @@