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 @@