From a305c96ad3ad1a28b326ba433f4831faac4b255c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:57:31 +0000 Subject: [PATCH 01/15] Add exception response observers before final customization Allow packages to inspect the prepared exception response without replacing the application response callback. Invoke boot-registered observers after request-local response callbacks and before respondUsing, ignoring observer return values. Expose beforeResponding through exception configuration and the facade, document its worker lifetime, and cover ordering, response identity, arguments, and ignored return values in the handler tests. This lets metadata integrations observe the actual error status before an Inertia error page renders. Validation: formatting and source/types analysis passed; FoundationExceptionsHandlerTest passed with 78 tests and 230 assertions. The complete framework run had no behavioral failures; its sole facade metadata failure was corrected and verified separately. --- src/docs/errors.md | 18 +++++++++ .../src/Configuration/Exceptions.php | 17 ++++++++ src/foundation/src/Exceptions/Handler.php | 26 +++++++++++++ src/support/src/Facades/Exceptions.php | 1 + .../FoundationExceptionsHandlerTest.php | 39 +++++++++++++++++++ 5 files changed, 101 insertions(+) diff --git a/src/docs/errors.md b/src/docs/errors.md index 3615b75495..2f23d09db9 100644 --- a/src/docs/errors.md +++ b/src/docs/errors.md @@ -322,6 +322,24 @@ use Symfony\Component\HttpFoundation\Response; }) ``` +If a package or application needs to inspect the prepared response without replacing your `respond` callback, register a `beforeResponding` callback during boot: + +```php +use Hypervel\Foundation\Configuration\Exceptions; +use Hypervel\Support\Facades\Log; +use Symfony\Component\HttpFoundation\Response; + +->withExceptions(function (Exceptions $exceptions): void { + $exceptions->beforeResponding(function (Response $response): void { + Log::debug('Exception response prepared.', [ + 'status' => $response->getStatusCode(), + ]); + }); +}) +``` + +These callbacks run in registration order, just before your `respond` callback. They receive the response, exception, and request; their return values are ignored. Callbacks are shared by every request in the worker, so read request-specific values inside the callback. Packages may register one from a service provider's `boot` method using the `Hypervel\Support\Facades\Exceptions::beforeResponding()` facade method. + ### Reportable and Renderable Exceptions diff --git a/src/foundation/src/Configuration/Exceptions.php b/src/foundation/src/Configuration/Exceptions.php index 52b59540c5..29f88678ab 100644 --- a/src/foundation/src/Configuration/Exceptions.php +++ b/src/foundation/src/Configuration/Exceptions.php @@ -8,9 +8,11 @@ use Hypervel\Foundation\Exceptions\Handler; use Hypervel\Foundation\Exceptions\ReportableHandler; use Hypervel\Http\Client\RequestException; +use Hypervel\Http\Request; use Hypervel\Support\Arr; use InvalidArgumentException; use Psr\Log\LogLevel; +use Symfony\Component\HttpFoundation\Response; use Throwable; class Exceptions @@ -83,6 +85,21 @@ public function respond(callable $using): static return $this; } + /** + * Register a callback before final exception response customization. + * + * Boot-only. The callback is shared by every request in the worker. + * Callback return values are ignored. + * + * @param callable(Response, Throwable, Request): mixed $callback + */ + public function beforeResponding(callable $callback): static + { + $this->handler->beforeResponding($callback); + + return $this; + } + /** * Specify the callback that should be used to throttle reportable exceptions. * diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index c36b02c1ed..87723491f6 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -154,6 +154,13 @@ class Handler implements ExceptionHandlerContract */ protected $finalizeResponseCallback; + /** + * The callbacks invoked before final response customization. + * + * @var array + */ + protected array $beforeRespondingCallbacks = []; + /** * The registered exception mappings. * @@ -847,11 +854,30 @@ protected function finalizeRenderedResponse(Request $request, SymfonyResponse $r } } + foreach ($this->beforeRespondingCallbacks as $callback) { + $callback($response, $e, $request); + } + return $this->finalizeResponseCallback ? call_user_func($this->finalizeResponseCallback, $response, $e, $request) : $response; } + /** + * Register a callback before final exception response customization. + * + * Boot-only. The callback is shared by every request in the worker. + * Callback return values are ignored. + * + * @param callable(SymfonyResponse, Throwable, Request): mixed $callback + */ + public function beforeResponding(callable $callback): static + { + $this->beforeRespondingCallbacks[] = Closure::fromCallable($callback); + + return $this; + } + /** * Register a callback to be called after an HTTP error response is rendered. */ diff --git a/src/support/src/Facades/Exceptions.php b/src/support/src/Facades/Exceptions.php index beb7ad978f..436a4b659c 100644 --- a/src/support/src/Facades/Exceptions.php +++ b/src/support/src/Facades/Exceptions.php @@ -11,6 +11,7 @@ /** * @method static void afterResponse(callable $callback) + * @method static \Hypervel\Foundation\Exceptions\Handler beforeResponding(callable $callback) * @method static array buildContextForException(Throwable $e) * @method static \Hypervel\Foundation\Exceptions\Handler buildContextUsing(\Closure $contextCallback) * @method static array contextForException(Throwable $e) diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 02f43ff245..7e31e4a0fb 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -21,8 +21,10 @@ use Hypervel\Database\RecordsNotFoundException; use Hypervel\Engine\Channel; use Hypervel\Foundation\Application; +use Hypervel\Foundation\Configuration\Exceptions as ExceptionsConfiguration; use Hypervel\Foundation\Exceptions\Handler; use Hypervel\Foundation\Testing\Concerns\InteractsWithExceptionHandling; +use Hypervel\Http\Exceptions\HttpResponseException; use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; use Hypervel\RateLimiter\AdmissionPolicy; @@ -1572,6 +1574,43 @@ public function testAfterResponseCallbacks(): void $this->assertSame('true', $response->headers->get('X-After-Error')); } + public function testBeforeRespondingObservesThePreparedResponseBeforeFinalCustomization(): void + { + $original = new SymfonyResponse('', 404); + $prepared = new SymfonyResponse('', 403); + $final = new SymfonyResponse('', 401); + $exception = new HttpResponseException($original); + $calls = []; + + $this->handler->afterResponse(function (SymfonyResponse $response) use ($original, $prepared, &$calls): SymfonyResponse { + $this->assertSame($original, $response); + $calls[] = 'afterResponse'; + + return $prepared; + }); + + (new ExceptionsConfiguration($this->handler))->beforeResponding( + function (SymfonyResponse $response, Throwable $error, Request $request) use ($prepared, $exception, &$calls): SymfonyResponse { + $this->assertSame($prepared, $response); + $this->assertSame($exception, $error); + $this->assertSame($this->request, $request); + $calls[] = 'beforeResponding'; + + return new SymfonyResponse('', 500); + } + ); + + $this->handler->respondUsing(function (SymfonyResponse $response) use ($prepared, $final, &$calls): SymfonyResponse { + $this->assertSame($prepared, $response); + $calls[] = 'respondUsing'; + + return $final; + }); + + $this->assertSame($final, $this->handler->render($this->request, $exception)); + $this->assertSame(['afterResponse', 'beforeResponding', 'respondUsing'], $calls); + } + protected function getConfig(array $config = []): Repository { return new Repository(array_merge([ From 2f1170e74967abcbe57164efa2cab34b81dc2bb7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:57:37 +0000 Subject: [PATCH 02/15] Report invalid JSON correctly for plain Symfony responses Read the optional response exception through null coalescing before rethrowing it. Plain Symfony responses have no exception property, so invalid JSON previously raised an undefined-property error instead of the intended assertion failure. Keep stored exception identity and the existing response proxy behavior unchanged. Add an exact-message regression alongside the stored-exception coverage. Validation: TestResponseTest passed with 259 tests and 1,030 assertions; formatting and source/types analysis passed. --- src/testing/src/TestResponse.php | 4 ++-- tests/Testing/TestResponseTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php index c25eb12574..102ac9676b 100644 --- a/src/testing/src/TestResponse.php +++ b/src/testing/src/TestResponse.php @@ -1111,8 +1111,8 @@ public function decodeResponseJson(): AssertableJsonString // JSON permits only space, tab, line feed, and carriage return around a value. if ($decodedResponse === null && trim($content, " \t\n\r") !== 'null') { - if ($this->exception) { - throw $this->exception; + if ($exception = $this->exception ?? null) { + throw $exception; } PHPUnit::withResponse($this)->fail('Invalid JSON was returned from the route.'); diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index 37ed9b36a3..64f17dd4c0 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -37,6 +37,7 @@ use stdClass; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\HttpFoundation\StreamedJsonResponse; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\VarDumper\VarDumper; @@ -3688,6 +3689,16 @@ public function testDecodeResponseJsonRejectsNullLikeContentWithInvalidWhitespac } } + public function testDecodeResponseJsonReportsInvalidJsonForAPlainSymfonyResponse(): void + { + $response = TestResponse::fromBaseResponse(new SymfonyResponse('invalid')); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageIs('Invalid JSON was returned from the route.'); + + $response->decodeResponseJson(); + } + public function testDecodeResponseJsonPreservesTheStoredResponseException(): void { $expected = new Exception('route failed'); From cc447a80ac29dbae1a8c729c8e3334b1c2050028 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:57:50 +0000 Subject: [PATCH 03/15] Port Head with coroutine-safe Blade and Inertia integration Port Laravel Head metadata builders, routing macros, structured data, facades, and rendering into the optional hypervel/head split package. Keep request data scoped and replicable while reusing worker-safe services and bounded metadata caches. Add boot-registered runtime defaults, error, and canonical-origin resolvers for request-specific metadata. Preserve eager upstream configuration, isolate copied schemas, and retain lazy Inertia sharing and partial reload behavior. Decorate SSR responses without mutating their readonly contents or changing the JavaScript protocol, and forward gateway capabilities. Correct canonical query and fragment handling, independent Open Graph fallback media, literal zero metadata, manifest replacement, title ownership, and shared schema-factory lookup. Capture actual exception response status before final customization. Wire split and development metadata, optional test cleanup, editor signatures, generated facade metadata, and PHPStan macro support using public reflection APIs. Validation: formatting and complete source/types analysis passed, including routing and facade fixtures. Head passed 180 tests and 640 assertions; facade metadata checks passed 2 tests and 16,228 assertions. Package-mode and dogfood checks passed. The initial full framework run completed with one facade metadata failure, subsequently fixed and verified by focused checks. --- composer.json | 4 + phpstan.neon.dist | 2 + phpstan.types.neon.dist | 2 + src/head/LICENSE.md | 23 + src/head/README.md | 12 + src/head/_ide_helper.php | 464 ++++++++++++++++ src/head/composer.json | 73 +++ src/head/extension.neon | 5 + src/head/src/Concerns/BuildsHead.php | 504 ++++++++++++++++++ src/head/src/CurrentHead.php | 69 +++ src/head/src/Enums/ImageType.php | 16 + src/head/src/Enums/Media.php | 13 + src/head/src/Enums/OfferAvailability.php | 29 + src/head/src/Enums/OgType.php | 21 + src/head/src/Enums/RobotsRule.php | 63 +++ src/head/src/Enums/TwitterCard.php | 13 + src/head/src/ErrorPages.php | 80 +++ src/head/src/Exceptions/InvalidSchema.php | 11 + src/head/src/Facades/Head.php | 102 ++++ src/head/src/Facades/Schema.php | 62 +++ src/head/src/HeadBuilder.php | 43 ++ src/head/src/HeadData.php | 118 ++++ src/head/src/HeadManager.php | 375 +++++++++++++ src/head/src/HeadServiceProvider.php | 81 +++ src/head/src/Inertia/Integration.php | 67 +++ src/head/src/Inertia/ShareHead.php | 39 ++ src/head/src/Inertia/UniqueHeadGateway.php | 104 ++++ src/head/src/PHPStan/RoutingMacros.php | 80 +++ src/head/src/PHPStan/WithHeadExtension.php | 65 +++ .../src/PHPStan/WithHeadMethodReflection.php | 155 ++++++ src/head/src/Rendering/HeadRenderer.php | 164 ++++++ src/head/src/Rendering/ResolvedHead.php | 169 ++++++ src/head/src/Rendering/TagRenderer.php | 221 ++++++++ src/head/src/Routing/HeadRouteMacros.php | 462 ++++++++++++++++ src/head/src/Routing/RouteAttributeParser.php | 192 +++++++ src/head/src/Schema/Article.php | 62 +++ src/head/src/Schema/BlogPosting.php | 12 + src/head/src/Schema/Brand.php | 35 ++ src/head/src/Schema/Breadcrumbs.php | 43 ++ src/head/src/Schema/Event.php | 70 +++ src/head/src/Schema/Faq.php | 45 ++ src/head/src/Schema/GenericSchemaObject.php | 9 + src/head/src/Schema/Offer.php | 36 ++ src/head/src/Schema/Organization.php | 35 ++ src/head/src/Schema/Person.php | 27 + src/head/src/Schema/Product.php | 63 +++ src/head/src/Schema/SchemaFactory.php | 90 ++++ src/head/src/Schema/SchemaObject.php | 147 +++++ src/head/src/Schema/SchemaValidator.php | 68 +++ src/head/src/Schema/WebPage.php | 35 ++ src/head/src/Schema/WebSite.php | 27 + src/head/src/SchemaType.php | 18 + src/head/src/TagRegistry.php | 120 +++++ src/head/src/Tags/AlternateLinks.php | 113 ++++ src/head/src/Tags/Canonical.php | 216 ++++++++ src/head/src/Tags/Description.php | 33 ++ src/head/src/Tags/FeedLinks.php | 193 +++++++ src/head/src/Tags/GenericLinks.php | 263 +++++++++ src/head/src/Tags/GroupedTagBuilder.php | 17 + src/head/src/Tags/MetaTags.php | 228 ++++++++ src/head/src/Tags/OpenGraph.php | 488 +++++++++++++++++ src/head/src/Tags/PaginationLinks.php | 114 ++++ src/head/src/Tags/PerformanceLinks.php | 438 +++++++++++++++ src/head/src/Tags/Robots.php | 87 +++ src/head/src/Tags/Schemas.php | 159 ++++++ src/head/src/Tags/StringTagBuilder.php | 76 +++ src/head/src/Tags/TagBuilder.php | 165 ++++++ src/head/src/Tags/Title.php | 142 +++++ src/head/src/Tags/Twitter.php | 263 +++++++++ .../src/PHPUnit/AfterEachTestSubscriber.php | 11 + types/Head/Facades.php | 38 ++ types/Head/Route.php | 89 ++++ 72 files changed, 7978 insertions(+) create mode 100644 src/head/LICENSE.md create mode 100644 src/head/README.md create mode 100644 src/head/_ide_helper.php create mode 100644 src/head/composer.json create mode 100644 src/head/extension.neon create mode 100644 src/head/src/Concerns/BuildsHead.php create mode 100644 src/head/src/CurrentHead.php create mode 100644 src/head/src/Enums/ImageType.php create mode 100644 src/head/src/Enums/Media.php create mode 100644 src/head/src/Enums/OfferAvailability.php create mode 100644 src/head/src/Enums/OgType.php create mode 100644 src/head/src/Enums/RobotsRule.php create mode 100644 src/head/src/Enums/TwitterCard.php create mode 100644 src/head/src/ErrorPages.php create mode 100644 src/head/src/Exceptions/InvalidSchema.php create mode 100644 src/head/src/Facades/Head.php create mode 100644 src/head/src/Facades/Schema.php create mode 100644 src/head/src/HeadBuilder.php create mode 100644 src/head/src/HeadData.php create mode 100644 src/head/src/HeadManager.php create mode 100644 src/head/src/HeadServiceProvider.php create mode 100644 src/head/src/Inertia/Integration.php create mode 100644 src/head/src/Inertia/ShareHead.php create mode 100644 src/head/src/Inertia/UniqueHeadGateway.php create mode 100644 src/head/src/PHPStan/RoutingMacros.php create mode 100644 src/head/src/PHPStan/WithHeadExtension.php create mode 100644 src/head/src/PHPStan/WithHeadMethodReflection.php create mode 100644 src/head/src/Rendering/HeadRenderer.php create mode 100644 src/head/src/Rendering/ResolvedHead.php create mode 100644 src/head/src/Rendering/TagRenderer.php create mode 100644 src/head/src/Routing/HeadRouteMacros.php create mode 100644 src/head/src/Routing/RouteAttributeParser.php create mode 100644 src/head/src/Schema/Article.php create mode 100644 src/head/src/Schema/BlogPosting.php create mode 100644 src/head/src/Schema/Brand.php create mode 100644 src/head/src/Schema/Breadcrumbs.php create mode 100644 src/head/src/Schema/Event.php create mode 100644 src/head/src/Schema/Faq.php create mode 100644 src/head/src/Schema/GenericSchemaObject.php create mode 100644 src/head/src/Schema/Offer.php create mode 100644 src/head/src/Schema/Organization.php create mode 100644 src/head/src/Schema/Person.php create mode 100644 src/head/src/Schema/Product.php create mode 100644 src/head/src/Schema/SchemaFactory.php create mode 100644 src/head/src/Schema/SchemaObject.php create mode 100644 src/head/src/Schema/SchemaValidator.php create mode 100644 src/head/src/Schema/WebPage.php create mode 100644 src/head/src/Schema/WebSite.php create mode 100644 src/head/src/SchemaType.php create mode 100644 src/head/src/TagRegistry.php create mode 100644 src/head/src/Tags/AlternateLinks.php create mode 100644 src/head/src/Tags/Canonical.php create mode 100644 src/head/src/Tags/Description.php create mode 100644 src/head/src/Tags/FeedLinks.php create mode 100644 src/head/src/Tags/GenericLinks.php create mode 100644 src/head/src/Tags/GroupedTagBuilder.php create mode 100644 src/head/src/Tags/MetaTags.php create mode 100644 src/head/src/Tags/OpenGraph.php create mode 100644 src/head/src/Tags/PaginationLinks.php create mode 100644 src/head/src/Tags/PerformanceLinks.php create mode 100644 src/head/src/Tags/Robots.php create mode 100644 src/head/src/Tags/Schemas.php create mode 100644 src/head/src/Tags/StringTagBuilder.php create mode 100644 src/head/src/Tags/TagBuilder.php create mode 100644 src/head/src/Tags/Title.php create mode 100644 src/head/src/Tags/Twitter.php create mode 100644 types/Head/Facades.php create mode 100644 types/Head/Route.php diff --git a/composer.json b/composer.json index 38bbbd8de5..ea66bd9eb1 100644 --- a/composer.json +++ b/composer.json @@ -177,6 +177,7 @@ "hypervel/foundation": "self.version", "hypervel/grpc": "self.version", "hypervel/hashing": "self.version", + "hypervel/head": "self.version", "hypervel/horizon": "self.version", "hypervel/http": "self.version", "hypervel/http-server": "self.version", @@ -266,6 +267,7 @@ "Hypervel\\Foundation\\": "src/foundation/src/", "Hypervel\\Grpc\\": "src/grpc/src/", "Hypervel\\Hashing\\": "src/hashing/src/", + "Hypervel\\Head\\": "src/head/src/", "Hypervel\\Horizon\\": "src/horizon/src/", "Hypervel\\HttpServer\\": "src/http-server/src/", "Hypervel\\Http\\": "src/http/src/", @@ -378,6 +380,7 @@ "hypervel": { "aliases": { "Feature": "Hypervel\\Pennant\\Feature", + "Head": "Hypervel\\Head\\Facades\\Head", "Mcp": "Hypervel\\Mcp\\Facades\\Mcp", "OpenTelemetry": "Hypervel\\OpenTelemetry\\Facades\\OpenTelemetry", "Saloon": "Hypervel\\Saloon\\Facades\\Saloon", @@ -401,6 +404,7 @@ "Hypervel\\Events\\EventServiceProvider", "Hypervel\\Filesystem\\FilesystemServiceProvider", "Hypervel\\Hashing\\HashingServiceProvider", + "Hypervel\\Head\\HeadServiceProvider", "Hypervel\\Horizon\\HorizonServiceProvider", "Hypervel\\Http\\HttpServiceProvider", "Hypervel\\Image\\ImageServiceProvider", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index df94d23afd..d69eab555a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,6 +6,7 @@ includes: - src/database/extension.neon - src/foundation/extension.neon + - src/head/extension.neon - src/scout/extension.neon parameters: @@ -27,6 +28,7 @@ parameters: - vendor/swoole/ide-helper/src/swoole/Swoole/Server.php excludePaths: - %currentWorkingDirectory%/src/helper/* + - %currentWorkingDirectory%/src/head/_ide_helper.php - %currentWorkingDirectory%/tests/* - %currentWorkingDirectory%/src/testbench/hypervel/* - %currentWorkingDirectory%/src/testbench/workbench/* diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist index e36ba13bcf..8f75c3d250 100644 --- a/phpstan.types.neon.dist +++ b/phpstan.types.neon.dist @@ -1,6 +1,7 @@ includes: - src/database/extension.neon - src/foundation/extension.neon + - src/head/extension.neon - src/scout/extension.neon parameters: @@ -9,6 +10,7 @@ parameters: scanDirectories: - src/database/src/PHPStan - src/foundation/src/PHPStan + - src/head/src/PHPStan - src/scout/src/PHPStan paths: - types diff --git a/src/head/LICENSE.md b/src/head/LICENSE.md new file mode 100644 index 0000000000..670aace448 --- /dev/null +++ b/src/head/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/head/README.md b/src/head/README.md new file mode 100644 index 0000000000..6e4ccb69b1 --- /dev/null +++ b/src/head/README.md @@ -0,0 +1,12 @@ +Head for Hypervel +=== + +Documentation: https://hypervel.org/docs/head + +## Differences From Laravel + +Head configuration is shared for the worker lifetime. Register defaults and error pages during boot; use the runtime resolvers for request-specific values. See [runtime defaults](https://hypervel.org/docs/head#runtime-defaults). + +Livewire integration is not provided. Use Blade or Inertia for page rendering. + +Ported from: https://github.com/laravel/head diff --git a/src/head/_ide_helper.php b/src/head/_ide_helper.php new file mode 100644 index 0000000000..8a468d4ea5 --- /dev/null +++ b/src/head/_ide_helper.php @@ -0,0 +1,464 @@ +|string $title + * @param null|array|string|true $canonical + * @param null|array|RobotsRule|string $robots + * @param null|array $og + * @param null|array|string $ogImage + * @param null|array|string $ogVideo + * @param null|array|string $ogAudio + * @param null|array $twitter + * @param null|array|string $twitterImage + * @param null|array|string $preload + * @param null|array|string $prefetch + * @param null|array|string $preconnect + * @param null|array|string $dnsPrefetch + * @param null|array $alternates + * @param null|array|string $feed + * @param null|array|string $icon + * @param null|array|string $favicon + * @param null|array|string $appleTouchIcon + * @param null|array|string $appleTouchStartupImage + * @param null|array|string $maskIcon + * @param null|array|string $manifest + * @param null|array $schema + * @param null|array $meta + * @param null|array $link + * @param array $extensions + */ + public function withHead( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): RouteRegistrar { + } + } +} + +namespace Hypervel\Routing { + use Hypervel\Head\Enums\RobotsRule; + + class RouteRegistrar + { + /** + * Define head metadata for the route, stored through Hypervel's native + * route metadata API. Provided by hypervel/head. + * + * @param null|array|string $title + * @param null|array|string|true $canonical + * @param null|array|RobotsRule|string $robots + * @param null|array $og + * @param null|array|string $ogImage + * @param null|array|string $ogVideo + * @param null|array|string $ogAudio + * @param null|array $twitter + * @param null|array|string $twitterImage + * @param null|array|string $preload + * @param null|array|string $prefetch + * @param null|array|string $preconnect + * @param null|array|string $dnsPrefetch + * @param null|array $alternates + * @param null|array|string $feed + * @param null|array|string $icon + * @param null|array|string $favicon + * @param null|array|string $appleTouchIcon + * @param null|array|string $appleTouchStartupImage + * @param null|array|string $maskIcon + * @param null|array|string $manifest + * @param null|array $schema + * @param null|array $meta + * @param null|array $link + * @param array $extensions + */ + public function withHead( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): RouteRegistrar { + } + } +} + +namespace Hypervel\Routing { + use Hypervel\Head\Enums\RobotsRule; + + class Route + { + /** + * Define head metadata for the route, stored through Hypervel's native + * route metadata API. Provided by hypervel/head. + * + * @param null|array|string $title + * @param null|array|string|true $canonical + * @param null|array|RobotsRule|string $robots + * @param null|array $og + * @param null|array|string $ogImage + * @param null|array|string $ogVideo + * @param null|array|string $ogAudio + * @param null|array $twitter + * @param null|array|string $twitterImage + * @param null|array|string $preload + * @param null|array|string $prefetch + * @param null|array|string $preconnect + * @param null|array|string $dnsPrefetch + * @param null|array $alternates + * @param null|array|string $feed + * @param null|array|string $icon + * @param null|array|string $favicon + * @param null|array|string $appleTouchIcon + * @param null|array|string $appleTouchStartupImage + * @param null|array|string $maskIcon + * @param null|array|string $manifest + * @param null|array $schema + * @param null|array $meta + * @param null|array $link + * @param array $extensions + */ + public function withHead( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): Route { + } + } +} + +namespace Hypervel\Routing { + use Hypervel\Head\Enums\RobotsRule; + + class PendingResourceRegistration + { + /** + * Define head metadata for the route, stored through Hypervel's native + * route metadata API. Provided by hypervel/head. + * + * @param null|array|string $title + * @param null|array|string|true $canonical + * @param null|array|RobotsRule|string $robots + * @param null|array $og + * @param null|array|string $ogImage + * @param null|array|string $ogVideo + * @param null|array|string $ogAudio + * @param null|array $twitter + * @param null|array|string $twitterImage + * @param null|array|string $preload + * @param null|array|string $prefetch + * @param null|array|string $preconnect + * @param null|array|string $dnsPrefetch + * @param null|array $alternates + * @param null|array|string $feed + * @param null|array|string $icon + * @param null|array|string $favicon + * @param null|array|string $appleTouchIcon + * @param null|array|string $appleTouchStartupImage + * @param null|array|string $maskIcon + * @param null|array|string $manifest + * @param null|array $schema + * @param null|array $meta + * @param null|array $link + * @param array $extensions + */ + public function withHead( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): PendingResourceRegistration { + } + } +} + +namespace Hypervel\Routing { + use Hypervel\Head\Enums\RobotsRule; + + class PendingSingletonResourceRegistration + { + /** + * Define head metadata for the route, stored through Hypervel's native + * route metadata API. Provided by hypervel/head. + * + * @param null|array|string $title + * @param null|array|string|true $canonical + * @param null|array|RobotsRule|string $robots + * @param null|array $og + * @param null|array|string $ogImage + * @param null|array|string $ogVideo + * @param null|array|string $ogAudio + * @param null|array $twitter + * @param null|array|string $twitterImage + * @param null|array|string $preload + * @param null|array|string $prefetch + * @param null|array|string $preconnect + * @param null|array|string $dnsPrefetch + * @param null|array $alternates + * @param null|array|string $feed + * @param null|array|string $icon + * @param null|array|string $favicon + * @param null|array|string $appleTouchIcon + * @param null|array|string $appleTouchStartupImage + * @param null|array|string $maskIcon + * @param null|array|string $manifest + * @param null|array $schema + * @param null|array $meta + * @param null|array $link + * @param array $extensions + */ + public function withHead( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): PendingSingletonResourceRegistration { + } + } +} + +namespace Hypervel\Support\Facades { + use Hypervel\Head\Enums\RobotsRule; + use Hypervel\Routing\RouteRegistrar; + + class Route + { + /** + * Define head metadata for the route, stored through Hypervel's native + * route metadata API. Provided by hypervel/head. + * + * @param null|array|string $title + * @param null|array|string|true $canonical + * @param null|array|RobotsRule|string $robots + * @param null|array $og + * @param null|array|string $ogImage + * @param null|array|string $ogVideo + * @param null|array|string $ogAudio + * @param null|array $twitter + * @param null|array|string $twitterImage + * @param null|array|string $preload + * @param null|array|string $prefetch + * @param null|array|string $preconnect + * @param null|array|string $dnsPrefetch + * @param null|array $alternates + * @param null|array|string $feed + * @param null|array|string $icon + * @param null|array|string $favicon + * @param null|array|string $appleTouchIcon + * @param null|array|string $appleTouchStartupImage + * @param null|array|string $maskIcon + * @param null|array|string $manifest + * @param null|array $schema + * @param null|array $meta + * @param null|array $link + * @param array $extensions + */ + public static function withHead( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): RouteRegistrar { + } + } +} diff --git a/src/head/composer.json b/src/head/composer.json new file mode 100644 index 0000000000..fde356bcde --- /dev/null +++ b/src/head/composer.json @@ -0,0 +1,73 @@ +{ + "name": "hypervel/head", + "description": "The Hypervel Head package.", + "license": "MIT", + "type": "library", + "keywords": [ + "php", + "head", + "seo", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "require": { + "php": "^8.4", + "hypervel/collections": "^0.4", + "hypervel/conditionable": "^0.4", + "hypervel/container": "^0.4", + "hypervel/context": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/http": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/routing": "^0.4", + "hypervel/support": "^0.4", + "hypervel/view": "^0.4", + "psr/log": "^3.0", + "symfony/http-foundation": "^8.1", + "symfony/http-kernel": "^8.1" + }, + "suggest": { + "hypervel/inertia": "Required for Inertia head management (^0.4)." + }, + "autoload": { + "psr-4": { + "Hypervel\\Head\\": "src/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + }, + "hypervel": { + "providers": [ + "Hypervel\\Head\\HeadServiceProvider" + ], + "aliases": { + "Head": "Hypervel\\Head\\Facades\\Head" + } + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + } +} diff --git a/src/head/extension.neon b/src/head/extension.neon new file mode 100644 index 0000000000..392e16dc33 --- /dev/null +++ b/src/head/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Hypervel\Head\PHPStan\WithHeadExtension + tags: + - phpstan.broker.methodsClassReflectionExtension diff --git a/src/head/src/Concerns/BuildsHead.php b/src/head/src/Concerns/BuildsHead.php new file mode 100644 index 0000000000..1f8fc368f7 --- /dev/null +++ b/src/head/src/Concerns/BuildsHead.php @@ -0,0 +1,504 @@ +headData()->overlayBuilder(Title::make($title, prefix: $prefix, suffix: $suffix, exact: $exact)); + + return $this; + } + + /** + * Set the page description. + */ + public function description(string $description): static + { + $this->headData()->overlayBuilder(Description::make($description)); + + return $this; + } + + /** + * Set the theme color for the given media. + */ + public function themeColor(string $color, Media|string|null $media = null): static + { + return $this->meta('theme-color', $color, media: $media); + } + + /** + * Set the application name. + */ + public function applicationName(string $name): static + { + return $this->meta('application-name', $name); + } + + /** + * Set the supported color schemes. + */ + public function colorScheme(string $scheme): static + { + return $this->meta('color-scheme', $scheme); + } + + /** + * Set the referrer policy. + */ + public function referrer(string $policy): static + { + return $this->meta('referrer', $policy); + } + + /** + * Set the viewport metadata. + */ + public function viewport(string $content): static + { + return $this->meta('viewport', $content); + } + + /** + * Set the Apple web app title. + */ + public function appleWebAppTitle(string $title): static + { + return $this->meta('apple-mobile-web-app-title', $title); + } + + /** + * Set whether the page supports web app mode. + */ + public function webAppCapable(bool $capable = true): static + { + return $this->meta('mobile-web-app-capable', $capable ? 'yes' : 'no'); + } + + /** + * Set the Apple web app status bar style. + */ + public function appleWebAppStatusBarStyle(string $style): static + { + return $this->meta('apple-mobile-web-app-status-bar-style', $style); + } + + /** + * Set the canonical URL and its normalization options. + */ + public function canonical(?string $url = null, ?bool $forceHttps = null, ?bool $trailingSlash = null): static + { + $this->headData()->overlayBuilder(Canonical::make($url, forceHttps: $forceHttps, trailingSlash: $trailingSlash)); + + return $this; + } + + /** + * Set the robots directives. + * + * @param array|RobotsRule|string $directives + */ + public function robots(string|RobotsRule|array $directives): static + { + $this->headData()->overlayBuilder(Robots::make($directives)); + + return $this; + } + + /** + * Add a robots meta tag with the "all" directive, allowing search engines to index the page. + */ + public function searchableByRobots(): static + { + return $this->robots(RobotsRule::All); + } + + /** + * Add a robots meta tag with the "none" directive, preventing search engines from indexing the page. + */ + public function hiddenFromRobots(): static + { + return $this->robots(RobotsRule::None); + } + + /** + * Set Open Graph metadata. + */ + public function og( + OgType|string|null $type = null, + ?string $title = null, + ?string $description = null, + ?string $url = null, + ?string $image = null, + ?string $video = null, + ?string $audio = null, + ?string $siteName = null, + ?string $locale = null, + ?string $determiner = null, + ): static { + $this->headData()->overlayBuilder(OpenGraph::make( + type: $type, + title: $title, + description: $description, + url: $url, + image: $image, + video: $video, + audio: $audio, + siteName: $siteName, + locale: $locale, + determiner: $determiner, + )); + + return $this; + } + + /** + * Add an Open Graph image. + */ + public function ogImage( + string $url, + ?string $alt = null, + ?int $width = null, + ?int $height = null, + ImageType|string|null $type = null, + ?string $secureUrl = null, + ): static { + $this->headData()->builder(OpenGraph::class)->image($url, alt: $alt, width: $width, height: $height, type: $type, secureUrl: $secureUrl); + + return $this; + } + + /** + * Add an Open Graph video. + */ + public function ogVideo( + string $url, + ?string $alt = null, + ?int $width = null, + ?int $height = null, + ?string $type = null, + ?string $secureUrl = null, + ): static { + $this->headData()->builder(OpenGraph::class)->video($url, alt: $alt, width: $width, height: $height, type: $type, secureUrl: $secureUrl); + + return $this; + } + + /** + * Add Open Graph audio. + */ + public function ogAudio(string $url, ?string $type = null, ?string $secureUrl = null): static + { + $this->headData()->builder(OpenGraph::class)->audio($url, type: $type, secureUrl: $secureUrl); + + return $this; + } + + /** + * Set Twitter card metadata. + */ + public function twitter( + TwitterCard|string|null $card = null, + ?string $site = null, + ?string $creator = null, + ?string $title = null, + ?string $description = null, + ?string $image = null, + ): static { + $this->headData()->overlayBuilder(Twitter::make( + card: $card, + site: $site, + creator: $creator, + title: $title, + description: $description, + image: $image, + )); + + return $this; + } + + /** + * Set the Twitter card image. + */ + public function twitterImage(string $url, ?string $alt = null): static + { + $this->headData()->builder(Twitter::class)->image($url, alt: $alt); + + return $this; + } + + /** + * Add a resource preload hint. + */ + public function preload(string $href, ?string $as = null, bool|string|null $crossorigin = null, ImageType|string|null $type = null, Media|string|null $media = null): static + { + $this->headData()->builder(PerformanceLinks::class)->preload($href, as: $as, crossorigin: $crossorigin, type: $type, media: $media); + + return $this; + } + + /** + * Add an asset preload hint. + */ + public function preloadAsset(string $path, ?string $as = null, bool|string|null $crossorigin = null, ImageType|string|null $type = null, Media|string|null $media = null): static + { + $this->headData()->builder(PerformanceLinks::class)->preloadAsset($path, as: $as, crossorigin: $crossorigin, type: $type, media: $media); + + return $this; + } + + /** + * Add a resource prefetch hint. + */ + public function prefetch(string $href, ?string $as = null): static + { + $this->headData()->builder(PerformanceLinks::class)->prefetch($href, as: $as); + + return $this; + } + + /** + * Add an asset prefetch hint. + */ + public function prefetchAsset(string $path, ?string $as = null): static + { + $this->headData()->builder(PerformanceLinks::class)->prefetchAsset($path, as: $as); + + return $this; + } + + /** + * Add a connection hint for an origin. + */ + public function preconnect(string $href, bool|string|null $crossorigin = null): static + { + $this->headData()->builder(PerformanceLinks::class)->preconnect($href, crossorigin: $crossorigin); + + return $this; + } + + /** + * Add a DNS prefetch hint. + */ + public function dnsPrefetch(string $href): static + { + $this->headData()->builder(PerformanceLinks::class)->dnsPrefetch($href); + + return $this; + } + + /** + * Add rel=prev/next links from a paginator instance. + * + * @param Paginator $paginator + */ + public function paginate(Paginator $paginator): static + { + $this->headData()->overlayBuilder(PaginationLinks::fromPaginator($paginator)); + + return $this; + } + + /** + * Add localized alternate URLs. + * + * @param array $alternates + */ + public function alternates(array $alternates): static + { + $this->headData()->overlayBuilder(new AlternateLinks($alternates)); + + return $this; + } + + /** + * Add a feed link. + */ + public function feed(string $href, string $title, string $type = 'rss'): static + { + $this->headData()->builder(FeedLinks::class)->feed($href, $title, $type); + + return $this; + } + + /** + * Add a JSON-LD schema object to the page. + * + * @param array|callable(SchemaFactory): (array|SchemaObject)|SchemaObject $schema + */ + public function schema(SchemaObject|array|callable $schema): static + { + if (is_callable($schema)) { + $schema = $schema($this->schemaFactory()); + } + + $this->headData()->builder(Schemas::class)->schema($schema); + + return $this; + } + + /** + * Add a named or property-based meta tag. + */ + public function meta(string $key, string $content, ?bool $property = null, Media|string|null $media = null): static + { + $this->headData()->builder(MetaTags::class)->tag($key, $content, $property, $media); + + return $this; + } + + /** + * Add a link with optional attributes. + * + * @param array $attributes + */ + public function link(string $rel, string $href, array $attributes = []): static + { + $this->headData()->builder(GenericLinks::class)->link($rel, $href, $attributes); + + return $this; + } + + /** + * Add an icon candidate. + */ + public function icon(string $href, ImageType|string|null $type = null, ?string $sizes = null, Media|string|null $media = null): static + { + return $this->link('icon', $href, static::linkAttributes([ + 'type' => $type instanceof ImageType ? $type->value : $type, + 'sizes' => $sizes, + 'media' => $media instanceof Media ? $media->value : $media, + ])); + } + + /** + * Add a favicon candidate. + */ + public function favicon(string $href, ImageType|string|null $type = null, ?string $sizes = null, Media|string|null $media = null): static + { + return $this->icon($href, type: $type, sizes: $sizes, media: $media); + } + + /** + * Add an Apple touch icon. + */ + public function appleTouchIcon(string $href, ?string $sizes = null): static + { + return $this->link('apple-touch-icon', $href, static::linkAttributes([ + 'sizes' => $sizes, + ])); + } + + /** + * Add a pinned-tab mask icon. + */ + public function maskIcon(string $href, ?string $color = null): static + { + return $this->link('mask-icon', $href, static::linkAttributes([ + 'color' => $color, + ])); + } + + /** + * Set the web app manifest link. + */ + public function manifest(string $href = '/site.webmanifest', bool|string|null $crossorigin = null): static + { + return $this->link('manifest', $href, static::linkAttributes([ + 'crossorigin' => $crossorigin, + ])); + } + + /** + * Add an Apple startup image. + */ + public function appleTouchStartupImage(string $href, Media|string|null $media = null): static + { + return $this->link('apple-touch-startup-image', $href, static::linkAttributes([ + 'media' => $media instanceof Media ? $media->value : $media, + ])); + } + + /** + * Set progressive web app metadata. + */ + public function pwa( + string $name, + string $manifest = '/site.webmanifest', + ?string $themeColor = null, + ?string $appleTouchIcon = null, + ?string $appleTouchIconSizes = '180x180', + ?string $appleWebAppStatusBarStyle = null, + ): static { + $this->applicationName($name) + ->appleWebAppTitle($name) + ->webAppCapable() + ->manifest($manifest); + + if (! is_null($themeColor)) { + $this->themeColor($themeColor); + } + + if (! is_null($appleTouchIcon)) { + $this->appleTouchIcon($appleTouchIcon, sizes: $appleTouchIconSizes); + } + + if (! is_null($appleWebAppStatusBarStyle)) { + $this->appleWebAppStatusBarStyle($appleWebAppStatusBarStyle); + } + + return $this; + } + + /** + * Remove unspecified link attributes. + * + * @param array $attributes + * @return array + */ + protected static function linkAttributes(array $attributes): array + { + return array_filter($attributes, fn (bool|float|int|string|null $value): bool => ! is_null($value)); + } +} diff --git a/src/head/src/CurrentHead.php b/src/head/src/CurrentHead.php new file mode 100644 index 0000000000..36fbba3593 --- /dev/null +++ b/src/head/src/CurrentHead.php @@ -0,0 +1,69 @@ +flush(); + } + + /** + * Get the current head data. + */ + public function data(): HeadData + { + return $this->data; + } + + /** + * Get the current response status. + */ + public function status(): ?int + { + return $this->status; + } + + /** + * Set the current response status. + */ + public function setStatus(int $status): void + { + $this->status = $status; + } + + /** + * Clear the current page data and response status. + */ + public function flush(): void + { + $this->data = new HeadData; + $this->status = null; + } + + /** + * Copy the current state for an independent coroutine. + */ + public function replicate(): static + { + $replica = clone $this; + $replica->data = $this->data->replicate(); + + return $replica; + } +} diff --git a/src/head/src/Enums/ImageType.php b/src/head/src/Enums/ImageType.php new file mode 100644 index 0000000000..ae7e1bb542 --- /dev/null +++ b/src/head/src/Enums/ImageType.php @@ -0,0 +1,16 @@ +value; + } +} diff --git a/src/head/src/Enums/OgType.php b/src/head/src/Enums/OgType.php new file mode 100644 index 0000000000..0092efc639 --- /dev/null +++ b/src/head/src/Enums/OgType.php @@ -0,0 +1,21 @@ + */ + protected array $statuses = []; + + /** + * Create the error page definitions. + * + * @param Closure(callable(HeadBuilder): mixed): HeadData $define + */ + public function __construct( + protected TagRegistry $registry, + protected Closure $define, + ) { + $this->defaults = new HeadData; + } + + /** + * Define head metadata for every error page, using either named + * route-attribute values or a single head builder callback. + * + * Boot-only. These defaults are shared by every request in the worker. + */ + public function defaults(mixed ...$head): static + { + $this->defaults = $this->apply($this->defaults, $head); + + return $this; + } + + /** + * Define head metadata for a specific error status, using either named + * route-attribute values or a single head builder callback. + * + * Boot-only. These definitions are shared by every request in the worker. + */ + public function status(int $status, mixed ...$head): static + { + $this->statuses[$status] = $this->apply(new HeadData, $head); + + return $this; + } + + /** + * Get the merged error head data for a status, or null when none is defined. + */ + public function forStatus(int $status): ?HeadData + { + if (! isset($this->statuses[$status]) && $this->defaults->isEmpty()) { + return null; + } + + return $this->defaults->merge($this->statuses[$status] ?? new HeadData); + } + + /** + * Apply a callback or route attributes to the given head data. + * + * @param array $head + */ + protected function apply(HeadData $base, array $head): HeadData + { + if (array_keys($head) === [0] && $head[0] instanceof Closure) { + return $base->merge(($this->define)($head[0])); + } + + return RouteAttributeParser::apply($base, RouteAttributeParser::arguments($head), $this->registry); + } +} diff --git a/src/head/src/Exceptions/InvalidSchema.php b/src/head/src/Exceptions/InvalidSchema.php new file mode 100644 index 0000000000..69d62eceeb --- /dev/null +++ b/src/head/src/Exceptions/InvalidSchema.php @@ -0,0 +1,11 @@ + $alternates) + * @method static \Hypervel\Head\HeadManager appleTouchIcon(string $href, string|null $sizes = null) + * @method static \Hypervel\Head\HeadManager appleTouchStartupImage(string $href, \Hypervel\Head\Enums\Media|string|null $media = null) + * @method static \Hypervel\Head\HeadManager appleWebAppStatusBarStyle(string $style) + * @method static \Hypervel\Head\HeadManager appleWebAppTitle(string $title) + * @method static \Hypervel\Head\HeadManager applicationName(string $name) + * @method static \Hypervel\Head\HeadManager canonical(string|null $url = null, bool|null $forceHttps = null, bool|null $trailingSlash = null) + * @method static \Hypervel\Head\HeadManager colorScheme(string $scheme) + * @method static \Hypervel\Head\HeadManager description(string $description) + * @method static \Hypervel\Head\HeadManager dnsPrefetch(string $href) + * @method static \Hypervel\Head\HeadManager favicon(string $href, \Hypervel\Head\Enums\ImageType|string|null $type = null, string|null $sizes = null, \Hypervel\Head\Enums\Media|string|null $media = null) + * @method static \Hypervel\Head\HeadManager feed(string $href, string $title, string $type = 'rss') + * @method static \Hypervel\Head\HeadManager flush() + * @method static void flushMacros() + * @method static bool hasMacro(string $name) + * @method static \Hypervel\Head\HeadManager hiddenFromRobots() + * @method static \Hypervel\Head\HeadManager icon(string $href, \Hypervel\Head\Enums\ImageType|string|null $type = null, string|null $sizes = null, \Hypervel\Head\Enums\Media|string|null $media = null) + * @method static \Hypervel\Head\HeadManager inertia(string $prop = 'head') + * @method static string inertiaProp() + * @method static \Hypervel\Head\HeadManager link(string $rel, string $href, array $attributes = []) + * @method static void macro(string $name, callable|object $macro) + * @method static \Hypervel\Head\HeadManager manifest(string $href = '/site.webmanifest', string|bool|null $crossorigin = null) + * @method static \Hypervel\Head\HeadManager maskIcon(string $href, string|null $color = null) + * @method static \Hypervel\Head\HeadManager meta(string $key, string $content, bool|null $property = null, \Hypervel\Head\Enums\Media|string|null $media = null) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static \Hypervel\Head\HeadManager og(\Hypervel\Head\Enums\OgType|string|null $type = null, string|null $title = null, string|null $description = null, string|null $url = null, string|null $image = null, string|null $video = null, string|null $audio = null, string|null $siteName = null, string|null $locale = null, string|null $determiner = null) + * @method static \Hypervel\Head\HeadManager ogAudio(string $url, string|null $type = null, string|null $secureUrl = null) + * @method static \Hypervel\Head\HeadManager ogImage(string $url, string|null $alt = null, int|null $width = null, int|null $height = null, \Hypervel\Head\Enums\ImageType|string|null $type = null, string|null $secureUrl = null) + * @method static \Hypervel\Head\HeadManager ogVideo(string $url, string|null $alt = null, int|null $width = null, int|null $height = null, string|null $type = null, string|null $secureUrl = null) + * @method static \Hypervel\Head\HeadManager paginate(\Hypervel\Contracts\Pagination\Paginator $paginator) + * @method static \Hypervel\Head\HeadManager preconnect(string $href, string|bool|null $crossorigin = null) + * @method static \Hypervel\Head\HeadManager prefetch(string $href, string|null $as = null) + * @method static \Hypervel\Head\HeadManager prefetchAsset(string $path, string|null $as = null) + * @method static \Hypervel\Head\HeadManager preload(string $href, string|null $as = null, string|bool|null $crossorigin = null, \Hypervel\Head\Enums\ImageType|string|null $type = null, \Hypervel\Head\Enums\Media|string|null $media = null) + * @method static \Hypervel\Head\HeadManager preloadAsset(string $path, string|null $as = null, string|bool|null $crossorigin = null, \Hypervel\Head\Enums\ImageType|string|null $type = null, \Hypervel\Head\Enums\Media|string|null $media = null) + * @method static \Hypervel\Head\HeadManager pwa(string $name, string $manifest = '/site.webmanifest', string|null $themeColor = null, string|null $appleTouchIcon = null, string|null $appleTouchIconSizes = '180x180', string|null $appleWebAppStatusBarStyle = null) + * @method static \Hypervel\Head\HeadManager referrer(string $policy) + * @method static \Hypervel\Support\HtmlString render(int|null $status = null) + * @method static \Hypervel\Support\HtmlString renderForView(int|null $status = null) + * @method static \Hypervel\Head\HeadManager robots(array|\Hypervel\Head\Enums\RobotsRule|string $directives) + * @method static \Hypervel\Head\HeadManager searchableByRobots() + * @method static \Hypervel\Head\HeadManager status(int $status) + * @method static \Hypervel\Head\HeadManager themeColor(string $color, \Hypervel\Head\Enums\Media|string|null $media = null) + * @method static \Hypervel\Head\HeadManager title(string $title, string|null $prefix = null, string|null $suffix = null, bool|null $exact = null) + * @method static array toArray(int|null $status = null) + * @method static array toElements(int|null $status = null) + * @method static string toHtml(int|null $status = null) + * @method static array toInertiaElements(int|null $status = null) + * @method static \Hypervel\Head\HeadManager twitter(\Hypervel\Head\Enums\TwitterCard|string|null $card = null, string|null $site = null, string|null $creator = null, string|null $title = null, string|null $description = null, string|null $image = null) + * @method static \Hypervel\Head\HeadManager twitterImage(string $url, string|null $alt = null) + * @method static \Hypervel\Head\HeadManager viewport(string $content) + * @method static \Hypervel\Head\HeadManager webAppCapable(bool $capable = true) + * + * @see \Hypervel\Head\HeadManager + * + * @mixin HeadManager + */ +class Head extends Facade +{ + /** + * Get methods that should be excluded from the generated facade docblock. + * + * The HeadManager mixin preserves callback, class-string and generic types + * that generated method tags flatten. + * + * @return array + */ + protected static function ignoredFacadeDocumenterMethods(): array + { + return [ + 'defaults', + 'errors', + 'inertiaGlobals', + 'resolveDefaultsUsing', + 'resolveErrorsUsing', + 'resolveCanonicalOriginUsing', + 'schema', + 'extend', + 'when', + 'unless', + ]; + } + + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'head'; + } +} diff --git a/src/head/src/Facades/Schema.php b/src/head/src/Facades/Schema.php new file mode 100644 index 0000000000..fb9d65cc18 --- /dev/null +++ b/src/head/src/Facades/Schema.php @@ -0,0 +1,62 @@ + + */ + protected static function ignoredFacadeDocumenterMethods(): array + { + return ['make', 'register']; + } + + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return 'head.schema'; + } +} diff --git a/src/head/src/HeadBuilder.php b/src/head/src/HeadBuilder.php new file mode 100644 index 0000000000..bf2ea812a9 --- /dev/null +++ b/src/head/src/HeadBuilder.php @@ -0,0 +1,43 @@ +data; + } + + /** + * Get the schema factory used to resolve schema callbacks. + */ + protected function schemaFactory(): SchemaFactory + { + // Directly constructed builders must also see registered custom schema types. + return $this->schemaFactory ??= Container::getInstance()->make(SchemaFactory::class); + } +} diff --git a/src/head/src/HeadData.php b/src/head/src/HeadData.php new file mode 100644 index 0000000000..cd806fed2b --- /dev/null +++ b/src/head/src/HeadData.php @@ -0,0 +1,118 @@ +, TagBuilder> */ + protected array $builders = []; + + /** + * Get all tag builders. + * + * @return array, TagBuilder> + */ + public function builders(): array + { + return $this->builders; + } + + /** + * Get an existing tag builder. + * + * @param class-string $builder + */ + public function get(string $builder): ?TagBuilder + { + return $this->builders[$builder] ?? null; + } + + /** + * Resolve a mutable tag builder instance, creating and storing it on first use. + * + * @template TBuilder of TagBuilder + * + * @param class-string $class + * @return TBuilder + */ + public function builder(string $class): TagBuilder + { + /** @var TBuilder */ + return $this->builders[$class] ??= new $class; + } + + /** + * Overlay the given builder onto any existing builder of the same type. + */ + public function overlayBuilder(TagBuilder $builder): static + { + $class = $builder::class; + $this->builders[$class] = $builder->overlayOn($this->builders[$class] ?? null); + + return $this; + } + + /** + * Merge the given data over this data into a new instance. + */ + public function merge(HeadData $data): self + { + $merged = clone $this; + + foreach ($data->builders as $builder) { + $merged->overlayBuilder($builder); + } + + return $merged; + } + + /** + * Determine whether the head data has no tags. + */ + public function isEmpty(): bool + { + foreach ($this->builders as $builder) { + if (! $builder->isEmpty()) { + return false; + } + } + + return true; + } + + /** + * Mark all builders as belonging to the defaults layer. + */ + public function asDefaults(): static + { + $defaults = clone $this; + + foreach ($defaults->builders as $class => $builder) { + $defaults->builders[$class] = $builder->asDefaults(); + } + + return $defaults; + } + + /** + * Copy the data and its builders for independent coroutine state. + */ + public function replicate(): static + { + $replica = clone $this; + + foreach ($this->builders as $class => $builder) { + $replica->builders[$class] = clone $builder; + } + + return $replica; + } +} diff --git a/src/head/src/HeadManager.php b/src/head/src/HeadManager.php new file mode 100644 index 0000000000..968737e450 --- /dev/null +++ b/src/head/src/HeadManager.php @@ -0,0 +1,375 @@ +> + */ +class HeadManager implements Arrayable, Htmlable +{ + use BuildsHead; + use Conditionable; + use Macroable; + + public const string INERTIA_PROP = 'head'; + + protected HeadData $inertiaGlobals; + + protected HeadData $defaults; + + protected string $inertiaProp = self::INERTIA_PROP; + + protected ErrorPages $errorPages; + + /** @var null|(Closure(HeadBuilder): mixed) */ + protected ?Closure $defaultsResolver = null; + + /** @var null|(Closure(HeadBuilder, int): mixed) */ + protected ?Closure $errorsResolver = null; + + /** + * Create the head manager. + */ + public function __construct( + protected Container $app, + protected HeadRenderer $renderer, + protected TagRegistry $registry, + ) { + $this->inertiaGlobals = new HeadData; + $this->defaults = new HeadData; + $this->errorPages = new ErrorPages( + $this->registry, + fn (callable $callback): HeadData => $this->define($callback), + ); + } + + /** + * Define head tags rendered as server-owned tags on every Inertia document. + * + * Boot-only. These tags are shared by every request in the worker. + * + * @param callable(HeadBuilder): mixed $callback + */ + public function inertiaGlobals(callable $callback): static + { + $this->inertiaGlobals = $this->inertiaGlobals->merge($this->define($callback)); + + return $this; + } + + /** + * Define default head data merged beneath every page's head. + * + * Boot-only. These defaults are shared by every request in the worker. + * + * @param callable(HeadBuilder): mixed $callback + */ + public function defaults(callable $callback): static + { + $this->defaults = $this->defaults->merge($this->define($callback)->asDefaults()); + + return $this; + } + + /** + * Resolve default head data from the current request context. + * + * Boot-only. The callback is shared by every request in the worker. + * + * @param null|(Closure(HeadBuilder): mixed) $resolver + */ + public function resolveDefaultsUsing(?Closure $resolver): static + { + $this->defaultsResolver = $resolver; + + return $this; + } + + /** + * Define head data for error pages. + * + * Boot-only. These error definitions are shared by every request in the worker. + * + * @param callable(ErrorPages): mixed $callback + */ + public function errors(callable $callback): static + { + $callback($this->errorPages); + + return $this; + } + + /** + * Resolve error head data from the current request context and status. + * + * Boot-only. The callback is shared by every request in the worker. + * + * @param null|(Closure(HeadBuilder, int): mixed) $resolver + */ + public function resolveErrorsUsing(?Closure $resolver): static + { + $this->errorsResolver = $resolver; + + return $this; + } + + /** + * Resolve the canonical origin from the current request context. + * + * Boot-only. The callback persists in a static property and affects every request in the worker. + * + * @param null|(Closure(): ?string) $resolver + */ + public function resolveCanonicalOriginUsing(?Closure $resolver): static + { + Canonical::resolveOriginUsing($resolver); + + return $this; + } + + /** + * Customize the Inertia prop the head elements are shared under. + * + * Boot-only. The prop name is shared by every request in the worker. + */ + public function inertia(string $prop = self::INERTIA_PROP): static + { + $this->inertiaProp = $prop; + + return $this; + } + + /** + * Get the shared Inertia prop name. + */ + public function inertiaProp(): string + { + return $this->inertiaProp; + } + + /** + * Register a custom tag builder to render on every head. + * + * Boot-only. Registered builders affect every request in the worker. + * + * @param class-string $builder + */ + public function extend(string $builder): static + { + $this->registry->extend($builder); + + return $this; + } + + /** + * Set the response status used to resolve error page head data. + */ + public function status(int $status): static + { + $this->state()->setStatus($status); + + return $this; + } + + /** + * Resolve the document metadata as an array. + * + * @return array + */ + public function toArray(?int $status = null): array + { + return $this->renderer->toArray($this->resolveDocument($status), $this->request()); + } + + /** + * Render the document head elements. + * + * @return array + */ + public function toElements(?int $status = null): array + { + return $this->renderer->toElements($this->resolveDocument($status), $this->request()); + } + + /** + * Render the page-managed Inertia head elements. + * + * @return array + */ + public function toInertiaElements(?int $status = null): array + { + return $this->renderer->toInertiaElements($this->resolve($status), $this->request()); + } + + /** + * Render the document head as HTML. + */ + public function render(?int $status = null): HtmlString + { + return new HtmlString($this->renderer->render($this->resolveDocument($status), $this->request())); + } + + /** + * Render tags for a Blade view, adding Inertia ownership attributes when + * the current request is rendering an Inertia page. + */ + public function renderForView(?int $status = null): HtmlString + { + if (! $this->renderingInertiaPage()) { + return $this->render($status); + } + + return new HtmlString($this->renderer->renderInertiaDocument( + $this->inertiaGlobals, + $this->resolve($status), + $this->request(), + )); + } + + /** + * Get the rendered document head HTML. + */ + public function toHtml(?int $status = null): string + { + return $this->render($status)->toHtml(); + } + + /** + * Flush runtime data for the current request scope. + */ + public function flush(): static + { + $this->state()->flush(); + + return $this; + } + + /** + * Run a definition callback against a fresh head builder and return its data. + */ + protected function define(callable $callback): HeadData + { + $data = new HeadData; + + $callback(new HeadBuilder($data, $this->schemaFactory())); + + return $data; + } + + /** + * Resolve all configured head layers into the final data for rendering. + */ + protected function resolve(?int $status = null): HeadData + { + $data = (new HeadData)->merge($this->defaults); + + if ($this->defaultsResolver !== null) { + $data = $data->merge($this->define($this->defaultsResolver)->asDefaults()); + } + + if ($route = $this->route()) { + foreach (RouteAttributeParser::routeMetadata($route) as $attributes) { + $data = RouteAttributeParser::apply($data, $attributes, $this->registry); + } + } + + $data = $data->merge($this->state()->data()); + + $errorStatus = $status ?? $this->state()->status(); + + if (! is_null($errorStatus) && $error = $this->errorPages->forStatus($errorStatus)) { + $data = $data->merge($error); + } + + if ($errorStatus !== null && $this->errorsResolver !== null) { + $data = $data->merge($this->define( + fn (HeadBuilder $head): mixed => ($this->errorsResolver)($head, $errorStatus), + )); + } + + return $data; + } + + /** + * Resolve server-owned globals beneath the page metadata. + */ + protected function resolveDocument(?int $status = null): HeadData + { + return (new HeadData) + ->merge($this->inertiaGlobals) + ->merge($this->resolve($status)); + } + + /** + * Determine if the current request is rendering an Inertia page. Inertia + * stores the page in coroutine-local state before the root view renders, so + * this holds at any depth of the view tree, unlike the view's variables. + */ + protected function renderingInertiaPage(): bool + { + return class_exists(InertiaState::class) + && (CoroutineContext::get(InertiaState::CONTEXT_KEY)->page ?? []) !== []; + } + + /** + * Get the head data instance the fluent methods should write to. + */ + protected function headData(): HeadData + { + return $this->state()->data(); + } + + /** + * Get the registered schema factory. + */ + protected function schemaFactory(): SchemaFactory + { + return $this->app->make(SchemaFactory::class); + } + + /** + * Get the current coroutine's head state. + */ + protected function state(): CurrentHead + { + return $this->app->make(CurrentHead::class); + } + + /** + * Get the current request or the application's console request. + */ + protected function request(): ?Request + { + return $this->app->make('request'); + } + + /** + * Get the current request's route. + */ + protected function route(): ?Route + { + $route = $this->request()?->route(); + + return $route instanceof Route ? $route : null; + } +} diff --git a/src/head/src/HeadServiceProvider.php b/src/head/src/HeadServiceProvider.php new file mode 100644 index 0000000000..12561645ac --- /dev/null +++ b/src/head/src/HeadServiceProvider.php @@ -0,0 +1,81 @@ +app->scoped(CurrentHead::class); + + $this->app->singleton('head.schema', fn (): SchemaFactory => new SchemaFactory); + $this->app->alias('head.schema', SchemaFactory::class); + + $this->app->singleton('head', fn (Container $app): HeadManager => new HeadManager( + $app, + $app->make(HeadRenderer::class), + $app->make(TagRegistry::class), + )); + $this->app->alias('head', HeadManager::class); + } + + /** + * Bootstrap the package services. + */ + public function boot(): void + { + Blade::directive( + 'head', + fn (string $expression): string => $expression === '' + ? "renderForView(); ?>" + : "renderForView({$expression}); ?>" + ); + + (new HeadRouteMacros)->register(); + + $this->registerExceptionStatusResolver(); + + // All providers must be registered before checking whether Inertia is loaded. + (new InertiaIntegration($this->app))->register(); + } + + /** + * Capture error status before Blade views and final response customization. + */ + protected function registerExceptionStatusResolver(): void + { + $this->callAfterResolving(ExceptionHandler::class, function (object $handler): void { + $head = $this->app->make(HeadManager::class); + + /** @var Handler $handler */ + $handler->renderable(function (Throwable $exception, Request $request) use ($head): void { + $head->status( + $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : 500 + ); + }); + + $handler->beforeResponding(function (Response $response) use ($head): void { + $head->status($response->getStatusCode()); + }); + }); + } +} diff --git a/src/head/src/Inertia/Integration.php b/src/head/src/Inertia/Integration.php new file mode 100644 index 0000000000..53c12e73e7 --- /dev/null +++ b/src/head/src/Inertia/Integration.php @@ -0,0 +1,67 @@ +app->providerIsLoaded(InertiaServiceProvider::class)) { + return; + } + + $this->decorateSsrGateway(); + $this->registerMiddleware(); + } + + /** + * Share head elements during every request in coroutine-local Inertia state. + */ + protected function registerMiddleware(): void + { + $push = static function (Kernel $kernel): void { + $kernel->pushMiddleware(ShareHead::class); + }; + + $this->app->afterResolving(Kernel::class, $push); + + if ($this->app->resolved(Kernel::class)) { + $push($this->app->make(Kernel::class)); + } + } + + /** + * Strip page-managed head elements from Inertia SSR responses at the + * gateway, so @head remains authoritative for the initial document no + * matter where it renders relative to Inertia's head component. + */ + protected function decorateSsrGateway(): void + { + $this->app->extend(Gateway::class, fn (Gateway $gateway): Gateway => new UniqueHeadGateway( + $gateway, + $this->app->make(HeadManager::class), + $this->app->make(HeadRenderer::class), + )); + } +} diff --git a/src/head/src/Inertia/ShareHead.php b/src/head/src/Inertia/ShareHead.php new file mode 100644 index 0000000000..df3df9bda7 --- /dev/null +++ b/src/head/src/Inertia/ShareHead.php @@ -0,0 +1,39 @@ +head->inertiaProp(), + fn (): array => $this->head->toInertiaElements(), + ); + + return $next($request); + } +} diff --git a/src/head/src/Inertia/UniqueHeadGateway.php b/src/head/src/Inertia/UniqueHeadGateway.php new file mode 100644 index 0000000000..34cffa7bb9 --- /dev/null +++ b/src/head/src/Inertia/UniqueHeadGateway.php @@ -0,0 +1,104 @@ + $page + */ + public function dispatch(array $page): ?Response + { + $response = $this->gateway->dispatch($page); + + if (is_null($response)) { + return null; + } + + /** @var array $elements */ + $elements = Arr::get($page, 'props.' . $this->head->inertiaProp(), []); + + return new Response( + $this->renderer->withoutInertiaElements($response->head, $elements), + $response->body, + ); + } + + /** + * Forward conditional SSR disabling to the decorated gateway. + */ + public function disable(Closure|bool $condition): void + { + if (! $this->gateway instanceof DisablesSsr) { + throw new LogicException('The configured SSR gateway does not support disabling server-side rendering conditionally.'); + } + + $this->gateway->disable($condition); + } + + /** + * Forward SSR path exclusions to the decorated gateway. + * + * @param array|string $paths + */ + public function except(array|string $paths): void + { + if (! $this->gateway instanceof ExcludesSsrPaths) { + throw new LogicException('The configured SSR gateway does not support excluding paths from server-side rendering.'); + } + + $this->gateway->except($paths); + } + + /** + * Forward SSR request configuration to the decorated gateway. + */ + public function configureRequestUsing(?Closure $callback = null): void + { + if (! $this->gateway instanceof ConfiguresSsrRequests) { + throw new LogicException('The configured SSR gateway does not support configuring server-side rendering requests.'); + } + + $this->gateway->configureRequestUsing($callback); + } + + /** + * Forward SSR health checks to the decorated gateway. + */ + public function isHealthy(): bool + { + if (! $this->gateway instanceof HasHealthCheck) { + throw new LogicException('The configured SSR gateway does not support health checks.'); + } + + return $this->gateway->isHealthy(); + } +} diff --git a/src/head/src/PHPStan/RoutingMacros.php b/src/head/src/PHPStan/RoutingMacros.php new file mode 100644 index 0000000000..d5cd549269 --- /dev/null +++ b/src/head/src/PHPStan/RoutingMacros.php @@ -0,0 +1,80 @@ +|string $title + * @param null|array|string|true $canonical + * @param null|array|RobotsRule|string $robots + * @param null|array $og + * @param null|array|string $ogImage + * @param null|array|string $ogVideo + * @param null|array|string $ogAudio + * @param null|array $twitter + * @param null|array|string $twitterImage + * @param null|array|string $preload + * @param null|array|string $prefetch + * @param null|array|string $preconnect + * @param null|array|string $dnsPrefetch + * @param null|array $alternates + * @param null|array|string $feed + * @param null|array|string $icon + * @param null|array|string $favicon + * @param null|array|string $appleTouchIcon + * @param null|array|string $appleTouchStartupImage + * @param null|array|string $maskIcon + * @param null|array|string $manifest + * @param null|array $schema + * @param null|array $meta + * @param null|array $link + * @param array $extensions + */ + public function withHead( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): object; +} diff --git a/src/head/src/PHPStan/WithHeadExtension.php b/src/head/src/PHPStan/WithHeadExtension.php new file mode 100644 index 0000000000..bc4edc3e99 --- /dev/null +++ b/src/head/src/PHPStan/WithHeadExtension.php @@ -0,0 +1,65 @@ +hasNativeMethod($methodName)) { + return false; + } + + foreach ([Router::class, RouteRegistrar::class, Route::class, PendingResourceRegistration::class, PendingSingletonResourceRegistration::class, RouteFacade::class] as $class) { + if ($classReflection->getAncestorWithClassName($class) !== null) { + return true; + } + } + + return false; + } + + /** + * Reflect the macro signature with the receiving routing object's return type. + */ + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + $method = $this->reflectionProvider->getClass(RoutingMacros::class)->getNativeMethod('withHead'); + $isStatic = $classReflection->getAncestorWithClassName(RouteFacade::class) !== null; + $returnType = $isStatic || $classReflection->getAncestorWithClassName(Router::class) !== null + ? new ObjectType(RouteRegistrar::class) + : new ThisType($classReflection); + + return new WithHeadMethodReflection( + $classReflection, + $method, + $returnType, + $isStatic, + ); + } +} diff --git a/src/head/src/PHPStan/WithHeadMethodReflection.php b/src/head/src/PHPStan/WithHeadMethodReflection.php new file mode 100644 index 0000000000..85d3792b44 --- /dev/null +++ b/src/head/src/PHPStan/WithHeadMethodReflection.php @@ -0,0 +1,155 @@ +signature->getName(); + } + + /** + * Return the declaring class. + */ + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + /** + * Determine whether the method is static. + */ + public function isStatic(): bool + { + return $this->isStatic; + } + + /** + * Determine whether the method is private. + */ + public function isPrivate(): bool + { + return $this->signature->isPrivate(); + } + + /** + * Determine whether the method is public. + */ + public function isPublic(): bool + { + return $this->signature->isPublic(); + } + + /** + * Return the method docblock. + */ + public function getDocComment(): ?string + { + return $this->signature->getDocComment(); + } + + /** + * Return the method prototype. + */ + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + /** + * Return the callable variants with the routing result type. + * + * @return list + */ + public function getVariants(): array + { + return array_map( + fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant( + $variant->getTemplateTypeMap(), + $variant->getResolvedTemplateTypeMap(), + $variant->getParameters(), + $variant->isVariadic(), + $this->returnType, + $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null, + ), + $this->signature->getVariants(), + ); + } + + /** + * Determine whether the method is deprecated. + */ + public function isDeprecated(): TrinaryLogic + { + return $this->signature->isDeprecated(); + } + + /** + * Return the deprecation description. + */ + public function getDeprecatedDescription(): ?string + { + return $this->signature->getDeprecatedDescription(); + } + + /** + * Determine whether the method is final. + */ + public function isFinal(): TrinaryLogic + { + return $this->signature->isFinal(); + } + + /** + * Determine whether the method is internal. + */ + public function isInternal(): TrinaryLogic + { + return $this->signature->isInternal(); + } + + /** + * Return the declared throw type. + */ + public function getThrowType(): ?Type + { + return $this->signature->getThrowType(); + } + + /** + * Determine whether the method has side effects. + */ + public function hasSideEffects(): TrinaryLogic + { + return $this->signature->hasSideEffects(); + } +} diff --git a/src/head/src/Rendering/HeadRenderer.php b/src/head/src/Rendering/HeadRenderer.php new file mode 100644 index 0000000000..e1801fcb25 --- /dev/null +++ b/src/head/src/Rendering/HeadRenderer.php @@ -0,0 +1,164 @@ +tags = $tags ?? new TagRenderer; + } + + /** + * Render the resolved head as an HTML string. + */ + public function render(HeadData $head, ?Request $request = null): string + { + return implode(PHP_EOL, $this->toElements($head, $request)); + } + + /** + * Render the initial Inertia document head as an HTML string. + */ + public function renderInertiaDocument(HeadData $inertiaGlobals, HeadData $head, ?Request $request = null): string + { + return implode(PHP_EOL, $this->toInertiaDocumentElements($inertiaGlobals, $head, $request)); + } + + /** + * Render the resolved head as individual HTML element strings. + * + * @return array + */ + public function toElements(HeadData $head, ?Request $request = null): array + { + return $this->elements($head, $request, $this->tags); + } + + /** + * Render the resolved head as individual HTML element strings with stable + * data-inertia keys for Inertia's server-managed head reconciler. + * + * @return array + */ + public function toInertiaElements(HeadData $head, ?Request $request = null): array + { + return $this->uniqueInertiaElements( + $this->elements($head, $request, $this->tags->withInertiaAttributes()), + ); + } + + /** + * Remove exact Inertia elements from an HTML string. + * + * @param array $elements + */ + public function withoutInertiaElements(string $html, array $elements): string + { + return str_replace($elements, '', $html); + } + + /** + * Render the initial Inertia document head. Inertia globals are emitted as + * plain, server-owned tags while page tags receive Inertia ownership keys. + * + * @return array + */ + public function toInertiaDocumentElements(HeadData $inertiaGlobals, HeadData $head, ?Request $request = null): array + { + return [ + ...$this->toElements($inertiaGlobals, $request), + ...$this->toInertiaElements($head, $request), + ]; + } + + /** + * Apply Inertia's last-write-wins ownership semantics to rendered elements. + * + * @param array $elements + * @return array + */ + protected function uniqueInertiaElements(array $elements): array + { + $unique = []; + $indexes = []; + + foreach ($elements as $element) { + $key = Str::match('/\sdata-inertia="([^"]+)"/', $element); + + // Inertia collects titles separately from all attribute-keyed elements. + $key = str_starts_with($element, ' + */ + protected function elements(HeadData $head, ?Request $request, TagRenderer $tags): array + { + $resolved = new ResolvedHead($head, $this->registry, $request, $this->schemas); + + return collect($resolved->builders()) + ->flatMap(fn (TagBuilder $builder): array => $builder->toTags($resolved, $tags)) + ->filter() + ->values() + ->all(); + } + + /** + * Render the resolved head as the array returned by Head::toArray(). + * + * @return array<string, mixed> + */ + public function toArray(HeadData $head, ?Request $request = null): array + { + $resolved = new ResolvedHead($head, $this->registry, $request, $this->schemas); + + $headArray = []; + + foreach ($this->registry->builders() as $builder) { + Arr::set($headArray, $builder::headArrayKey(), $resolved->headArray($builder)); + } + + return $headArray; + } +} diff --git a/src/head/src/Rendering/ResolvedHead.php b/src/head/src/Rendering/ResolvedHead.php new file mode 100644 index 0000000000..f2d4e0ba6b --- /dev/null +++ b/src/head/src/Rendering/ResolvedHead.php @@ -0,0 +1,169 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Rendering; + +use Hypervel\Contracts\Container\Transient; +use Hypervel\Head\HeadData; +use Hypervel\Head\Schema\SchemaValidator; +use Hypervel\Head\TagRegistry; +use Hypervel\Head\Tags\Canonical; +use Hypervel\Head\Tags\Description; +use Hypervel\Head\Tags\OpenGraph; +use Hypervel\Head\Tags\Robots; +use Hypervel\Head\Tags\TagBuilder; +use Hypervel\Head\Tags\Title; +use Hypervel\Http\Request; +use Hypervel\Support\Arr; + +class ResolvedHead implements Transient +{ + /** + * Create a resolved view of the page metadata. + */ + public function __construct( + protected HeadData $data, + protected TagRegistry $registry, + protected ?Request $request = null, + protected ?SchemaValidator $schemas = null, + ) { + } + + /** + * Get the resolved head data. + */ + public function data(): HeadData + { + return $this->data; + } + + /** + * Get the request used for rendering. + */ + public function request(): ?Request + { + return $this->request; + } + + /** + * Validate a structured data definition. + * + * @param array<string, mixed> $schema + */ + public function validateSchema(array $schema): void + { + $this->schemas?->validate($schema); + } + + /** + * Get the resolved title. + */ + public function title(): ?string + { + $title = $this->builder(Title::class); + + return $title instanceof Title ? $title->render() : null; + } + + /** + * Get the resolved description. + */ + public function description(): ?string + { + $description = $this->builder(Description::class); + + return $description instanceof Description ? $description->render() : null; + } + + /** + * Get the resolved canonical URL. + */ + public function canonical(): ?string + { + $canonical = $this->builder(Canonical::class); + + return $canonical instanceof Canonical ? $canonical->render($this->request) : null; + } + + /** + * Get the resolved robots directives. + */ + public function robots(): ?string + { + $robots = $this->builder(Robots::class); + + return $robots instanceof Robots ? $robots->render() : null; + } + + /** + * Get the first Open Graph image and its alternative text. + * + * @return null|array<string, string> + */ + public function openGraphImage(): ?array + { + $openGraph = $this->builder(OpenGraph::class); + + if (! $openGraph instanceof OpenGraph || ($images = $openGraph->images()) === []) { + return null; + } + + $image = reset($images); + + return Arr::whereNotNull([ + 'url' => $image['url'], + 'alt' => $image['alt'] ?? null, + ]); + } + + /** + * Get the tag builders to render, in declared order, including those that derive + * their values from the rest of the head even when never set explicitly. + * + * @return array<int, TagBuilder> + */ + public function builders(): array + { + $builders = []; + + foreach ($this->registry->builders() as $builder) { + $value = $this->builder($builder); + + if ($value instanceof TagBuilder) { + $builders[] = $value; + } elseif ($builder::rendersWhenEmpty($this)) { + $builders[] = new $builder; + } + } + + return $builders; + } + + /** + * Resolve one tag builder into its Head::toArray() value, + * including defaults for builder data that was never set. + * + * @param class-string<TagBuilder> $builder + */ + public function headArray(string $builder): mixed + { + $value = $this->builder($builder); + + if (! $value instanceof TagBuilder && $builder::rendersWhenEmpty($this)) { + $value = new $builder; + } + + return $value?->toHeadArray($this) ?? $builder::headArrayDefault(); + } + + /** + * Get an existing tag builder from the resolved data. + * + * @param class-string<TagBuilder> $builder + */ + public function builder(string $builder): ?TagBuilder + { + return $this->data->get($builder); + } +} diff --git a/src/head/src/Rendering/TagRenderer.php b/src/head/src/Rendering/TagRenderer.php new file mode 100644 index 0000000000..d2a1fe1617 --- /dev/null +++ b/src/head/src/Rendering/TagRenderer.php @@ -0,0 +1,221 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Rendering; + +use InvalidArgumentException; + +class TagRenderer +{ + /** + * Create the tag renderer. + */ + public function __construct(protected bool $withInertiaAttributes = false) + { + } + + /** + * Get a renderer that stamps tags with data-inertia ownership attributes. + */ + public function withInertiaAttributes(): self + { + return new self(true); + } + + /** + * Render an escaped title tag. + */ + public function title(string $title, ?string $inertiaKey = null): string + { + $inertiaKey ??= 'title'; + + return $this->element('title', e($title), $this->inertiaAttributes($inertiaKey)); + } + + /** + * Render a meta tag keyed by the given attribute ("name" or "property"). + */ + public function meta(string $attribute, string $key, string|int|float|bool $content, ?string $inertiaKey = null): string + { + $inertiaKey ??= $key; + + return $this->metaWithAttributes($attribute, $key, ['content' => (string) $content], $inertiaKey); + } + + /** + * Render a meta tag keyed by the given attribute ("name" or "property") with arbitrary attributes. + * + * @param array<string, null|bool|float|int|string> $attributes + */ + public function metaWithAttributes(string $attribute, string $key, array $attributes, ?string $inertiaKey = null): string + { + $inertiaKey ??= $key; + + return $this->voidElement( + 'meta', + $this->inertiaAttributes($inertiaKey), + [$attribute => $key], + $attributes, + ); + } + + /** + * Render a link tag. + */ + public function link(string $rel, string $href, ?string $inertiaKey = null): string + { + $inertiaKey ??= $this->stableKey($rel, $href); + + return $this->voidElement( + 'link', + $this->inertiaAttributes($inertiaKey), + ['rel' => $rel, 'href' => $href], + ); + } + + /** + * Render a link tag with arbitrary attributes. + * + * @param array<string, null|bool|float|int|string> $attributes + */ + public function linkWithAttributes(string $rel, array $attributes, ?string $inertiaKey = null): string + { + $inertiaKey ??= $this->stableKey($rel, (string) ($attributes['href'] ?? serialize($attributes))); + + return $this->voidElement( + 'link', + $this->inertiaAttributes($inertiaKey), + ['rel' => $rel], + $attributes, + ); + } + + /** + * Render structured data with script-safe JSON encoding. + * + * @param array<string, mixed> $schema + */ + public function jsonLd(array $schema, ?string $inertiaKey = null): string + { + $json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); + $inertiaKey ??= $this->stableKey('schema', $json); + + return $this->element( + 'script', + $json, + $this->inertiaAttributes($inertiaKey), + ['type' => 'application/ld+json'], + ); + } + + /** + * Render escaped HTML attributes. + * + * @param array<string, null|bool|float|int|string> $attributes + */ + public function attributes(array $attributes): string + { + return $this->renderAttributes($attributes); + } + + /** + * Attribute names aren't HTML-escaped, so reject anything that could + * break out of the attribute position. The "u" modifier also makes + * invalid UTF-8 fail the match. + */ + protected function isValidAttributeName(string $name): bool + { + return preg_match('/^[^\x00-\x20\x7F-\x9F"\'\/=>]+$/u', $name) === 1; + } + + /** + * Generate a stable, content-derived data-inertia key for a tag. + */ + public function stableKey(string $prefix, string $value): string + { + return $prefix . ':' . substr(hash('xxh128', $value), 0, 16); + } + + /** + * Validate and escape the combined attribute sets. + * + * @param array<int|string, null|bool|float|int|string> ...$attributeSets + */ + protected function renderAttributes(array ...$attributeSets): string + { + $rendered = []; + $seen = []; + + foreach ($attributeSets as $attributes) { + foreach ($attributes as $name => $value) { + if ($value === false || is_null($value)) { + continue; + } + + $name = (string) $name; + + if (! $this->isValidAttributeName($name)) { + throw new InvalidArgumentException('Invalid HTML attribute name.'); + } + + $normalizedName = strtolower($name); + + if (isset($seen[$normalizedName])) { + throw new InvalidArgumentException("Duplicate HTML attribute name [{$name}]."); + } + + $seen[$normalizedName] = true; + $rendered[] = $value === true + ? $name + : $name . '="' . e((string) $value) . '"'; + } + } + + return implode(' ', $rendered); + } + + /** + * Render a void HTML element. + * + * @param array<int|string, null|bool|float|int|string> ...$attributeSets + */ + protected function voidElement(string $name, array ...$attributeSets): string + { + return '<' . $name . $this->renderedAttributes(...$attributeSets) . '>'; + } + + /** + * Render an HTML element with content. + * + * @param array<int|string, null|bool|float|int|string> ...$attributeSets + */ + protected function element(string $name, string $content, array ...$attributeSets): string + { + return '<' . $name . $this->renderedAttributes(...$attributeSets) . '>' . $content . '</' . $name . '>'; + } + + /** + * Render attributes with their leading separator. + * + * @param array<int|string, null|bool|float|int|string> ...$attributeSets + */ + protected function renderedAttributes(array ...$attributeSets): string + { + $attributes = $this->renderAttributes(...$attributeSets); + + return $attributes === '' ? '' : ' ' . $attributes; + } + + /** + * Get the ownership attribute when Inertia rendering is enabled. + * + * @return array<string, string> + */ + protected function inertiaAttributes(?string $key): array + { + return $this->withInertiaAttributes && ! is_null($key) + ? ['data-inertia' => $key] + : []; + } +} diff --git a/src/head/src/Routing/HeadRouteMacros.php b/src/head/src/Routing/HeadRouteMacros.php new file mode 100644 index 0000000000..33585afdbd --- /dev/null +++ b/src/head/src/Routing/HeadRouteMacros.php @@ -0,0 +1,462 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Routing; + +use Closure; +use Hypervel\Head\Enums\RobotsRule; +use Hypervel\Routing\PendingResourceRegistration; +use Hypervel\Routing\PendingSingletonResourceRegistration; +use Hypervel\Routing\Route; +use Hypervel\Routing\Router; +use Hypervel\Routing\RouteRegistrar; + +/** + * Registers the withHead() route macros. + * + * Each macro carries the full named-argument signature for the built-in + * route attributes so editors and static analysis can validate attribute + * names at every chain position; keep the five signatures, _ide_helper.php, + * and PHPStan's RoutingMacros signature in sync when adding route attributes. + * The macros delegate to Hypervel's native route metadata API immediately, + * so routes keep storing plain, cache-friendly arrays. + */ +class HeadRouteMacros +{ + /** + * Register the withHead() macro on every routing surface. + * + * Boot-only. These static macros affect every request in the worker. + */ + public function register(): void + { + Router::macro('withHead', $this->routerMacro()); + RouteRegistrar::macro('withHead', $this->registrarMacro()); + Route::macro('withHead', $this->routeMacro()); + PendingResourceRegistration::macro('withHead', $this->pendingResourceMacro()); + PendingSingletonResourceRegistration::macro('withHead', $this->pendingSingletonMacro()); + } + + /** + * Recover the typed routing object a macro closure was bound to. + * + * Macroable rebinds each macro closure at call time, so inside the + * closure $this is the routing object rather than this registrar. + * + * @template TScope of object + * + * @param class-string<TScope> $scope + * @return TScope + */ + public static function bound(mixed $context, string $scope): object + { + /** @var TScope $context */ + return $context; + } + + /** + * Create the withHead() macro for group-position calls on the router, + * such as Route::withHead(...)->prefix('admin')->group(...). + */ + protected function routerMacro(): Closure + { + /** + * @param null|array<mixed, mixed>|string $title + * @param null|array<mixed, mixed>|string|true $canonical + * @param null|array<int, RobotsRule|string>|RobotsRule|string $robots + * @param null|array<string, mixed> $og + * @param null|array<mixed, mixed>|string $ogImage + * @param null|array<mixed, mixed>|string $ogVideo + * @param null|array<mixed, mixed>|string $ogAudio + * @param null|array<string, mixed> $twitter + * @param null|array<mixed, mixed>|string $twitterImage + * @param null|array<mixed, mixed>|string $preload + * @param null|array<mixed, mixed>|string $prefetch + * @param null|array<mixed, mixed>|string $preconnect + * @param null|array<mixed, mixed>|string $dnsPrefetch + * @param null|array<string, string> $alternates + * @param null|array<mixed, mixed>|string $feed + * @param null|array<mixed, mixed>|string $icon + * @param null|array<mixed, mixed>|string $favicon + * @param null|array<mixed, mixed>|string $appleTouchIcon + * @param null|array<mixed, mixed>|string $appleTouchStartupImage + * @param null|array<mixed, mixed>|string $maskIcon + * @param null|array<mixed, mixed>|string $manifest + * @param null|array<mixed, mixed> $schema + * @param null|array<mixed, mixed> $meta + * @param null|array<mixed, mixed> $link + * @param array<string, mixed> $extensions + */ + $macro = function ( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): RouteRegistrar { + $arguments = get_defined_vars(); + + $router = HeadRouteMacros::bound($this, Router::class); + + return (new RouteRegistrar($router))->metadata( + RouteAttributeParser::metadataFromArguments($arguments) + ); + }; + + return $macro; + } + + /** + * Create the withHead() macro for chained group-position calls, such as + * Route::prefix('admin')->withHead(...)->group(...). + */ + protected function registrarMacro(): Closure + { + /** + * @param null|array<mixed, mixed>|string $title + * @param null|array<mixed, mixed>|string|true $canonical + * @param null|array<int, RobotsRule|string>|RobotsRule|string $robots + * @param null|array<string, mixed> $og + * @param null|array<mixed, mixed>|string $ogImage + * @param null|array<mixed, mixed>|string $ogVideo + * @param null|array<mixed, mixed>|string $ogAudio + * @param null|array<string, mixed> $twitter + * @param null|array<mixed, mixed>|string $twitterImage + * @param null|array<mixed, mixed>|string $preload + * @param null|array<mixed, mixed>|string $prefetch + * @param null|array<mixed, mixed>|string $preconnect + * @param null|array<mixed, mixed>|string $dnsPrefetch + * @param null|array<string, string> $alternates + * @param null|array<mixed, mixed>|string $feed + * @param null|array<mixed, mixed>|string $icon + * @param null|array<mixed, mixed>|string $favicon + * @param null|array<mixed, mixed>|string $appleTouchIcon + * @param null|array<mixed, mixed>|string $appleTouchStartupImage + * @param null|array<mixed, mixed>|string $maskIcon + * @param null|array<mixed, mixed>|string $manifest + * @param null|array<mixed, mixed> $schema + * @param null|array<mixed, mixed> $meta + * @param null|array<mixed, mixed> $link + * @param array<string, mixed> $extensions + */ + $macro = function ( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): RouteRegistrar { + $arguments = get_defined_vars(); + + $registrar = HeadRouteMacros::bound($this, RouteRegistrar::class); + + return $registrar->metadata( + RouteAttributeParser::metadataFromArguments($arguments) + ); + }; + + return $macro; + } + + /** + * Create the withHead() macro for individual routes, such as + * Route::view('/dashboard', 'dashboard')->withHead(...). + */ + protected function routeMacro(): Closure + { + /** + * @param null|array<mixed, mixed>|string $title + * @param null|array<mixed, mixed>|string|true $canonical + * @param null|array<int, RobotsRule|string>|RobotsRule|string $robots + * @param null|array<string, mixed> $og + * @param null|array<mixed, mixed>|string $ogImage + * @param null|array<mixed, mixed>|string $ogVideo + * @param null|array<mixed, mixed>|string $ogAudio + * @param null|array<string, mixed> $twitter + * @param null|array<mixed, mixed>|string $twitterImage + * @param null|array<mixed, mixed>|string $preload + * @param null|array<mixed, mixed>|string $prefetch + * @param null|array<mixed, mixed>|string $preconnect + * @param null|array<mixed, mixed>|string $dnsPrefetch + * @param null|array<string, string> $alternates + * @param null|array<mixed, mixed>|string $feed + * @param null|array<mixed, mixed>|string $icon + * @param null|array<mixed, mixed>|string $favicon + * @param null|array<mixed, mixed>|string $appleTouchIcon + * @param null|array<mixed, mixed>|string $appleTouchStartupImage + * @param null|array<mixed, mixed>|string $maskIcon + * @param null|array<mixed, mixed>|string $manifest + * @param null|array<mixed, mixed> $schema + * @param null|array<mixed, mixed> $meta + * @param null|array<mixed, mixed> $link + * @param array<string, mixed> $extensions + */ + $macro = function ( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): Route { + $arguments = get_defined_vars(); + + $route = HeadRouteMacros::bound($this, Route::class); + + return $route->metadata( + RouteAttributeParser::metadataFromArguments($arguments) + ); + }; + + return $macro; + } + + /** + * Create the withHead() macro for resource registrations, such as + * Route::resource('posts', PostController::class)->withHead(...). + */ + protected function pendingResourceMacro(): Closure + { + /** + * @param null|array<mixed, mixed>|string $title + * @param null|array<mixed, mixed>|string|true $canonical + * @param null|array<int, RobotsRule|string>|RobotsRule|string $robots + * @param null|array<string, mixed> $og + * @param null|array<mixed, mixed>|string $ogImage + * @param null|array<mixed, mixed>|string $ogVideo + * @param null|array<mixed, mixed>|string $ogAudio + * @param null|array<string, mixed> $twitter + * @param null|array<mixed, mixed>|string $twitterImage + * @param null|array<mixed, mixed>|string $preload + * @param null|array<mixed, mixed>|string $prefetch + * @param null|array<mixed, mixed>|string $preconnect + * @param null|array<mixed, mixed>|string $dnsPrefetch + * @param null|array<string, string> $alternates + * @param null|array<mixed, mixed>|string $feed + * @param null|array<mixed, mixed>|string $icon + * @param null|array<mixed, mixed>|string $favicon + * @param null|array<mixed, mixed>|string $appleTouchIcon + * @param null|array<mixed, mixed>|string $appleTouchStartupImage + * @param null|array<mixed, mixed>|string $maskIcon + * @param null|array<mixed, mixed>|string $manifest + * @param null|array<mixed, mixed> $schema + * @param null|array<mixed, mixed> $meta + * @param null|array<mixed, mixed> $link + * @param array<string, mixed> $extensions + */ + $macro = function ( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): PendingResourceRegistration { + $arguments = get_defined_vars(); + + $registration = HeadRouteMacros::bound($this, PendingResourceRegistration::class); + + return $registration->metadata( + RouteAttributeParser::metadataFromArguments($arguments) + ); + }; + + return $macro; + } + + /** + * Create the withHead() macro for singleton resource registrations, such + * as Route::singleton('profile', ProfileController::class)->withHead(...). + */ + protected function pendingSingletonMacro(): Closure + { + /** + * @param null|array<mixed, mixed>|string $title + * @param null|array<mixed, mixed>|string|true $canonical + * @param null|array<int, RobotsRule|string>|RobotsRule|string $robots + * @param null|array<string, mixed> $og + * @param null|array<mixed, mixed>|string $ogImage + * @param null|array<mixed, mixed>|string $ogVideo + * @param null|array<mixed, mixed>|string $ogAudio + * @param null|array<string, mixed> $twitter + * @param null|array<mixed, mixed>|string $twitterImage + * @param null|array<mixed, mixed>|string $preload + * @param null|array<mixed, mixed>|string $prefetch + * @param null|array<mixed, mixed>|string $preconnect + * @param null|array<mixed, mixed>|string $dnsPrefetch + * @param null|array<string, string> $alternates + * @param null|array<mixed, mixed>|string $feed + * @param null|array<mixed, mixed>|string $icon + * @param null|array<mixed, mixed>|string $favicon + * @param null|array<mixed, mixed>|string $appleTouchIcon + * @param null|array<mixed, mixed>|string $appleTouchStartupImage + * @param null|array<mixed, mixed>|string $maskIcon + * @param null|array<mixed, mixed>|string $manifest + * @param null|array<mixed, mixed> $schema + * @param null|array<mixed, mixed> $meta + * @param null|array<mixed, mixed> $link + * @param array<string, mixed> $extensions + */ + $macro = function ( + string|array|null $title = null, + ?string $description = null, + string|array|true|null $canonical = null, + string|RobotsRule|array|null $robots = null, + ?string $themeColor = null, + ?string $applicationName = null, + ?string $colorScheme = null, + ?string $referrer = null, + ?string $viewport = null, + ?string $appleWebAppTitle = null, + ?bool $webAppCapable = null, + ?string $appleWebAppStatusBarStyle = null, + ?array $og = null, + string|array|null $ogImage = null, + string|array|null $ogVideo = null, + string|array|null $ogAudio = null, + ?array $twitter = null, + string|array|null $twitterImage = null, + string|array|null $preload = null, + string|array|null $prefetch = null, + string|array|null $preconnect = null, + string|array|null $dnsPrefetch = null, + ?array $alternates = null, + string|array|null $feed = null, + string|array|null $icon = null, + string|array|null $favicon = null, + string|array|null $appleTouchIcon = null, + string|array|null $appleTouchStartupImage = null, + string|array|null $maskIcon = null, + string|array|null $manifest = null, + ?array $schema = null, + ?array $meta = null, + ?array $link = null, + array $extensions = [], + ): PendingSingletonResourceRegistration { + $arguments = get_defined_vars(); + + $registration = HeadRouteMacros::bound($this, PendingSingletonResourceRegistration::class); + + return $registration->metadata( + RouteAttributeParser::metadataFromArguments($arguments) + ); + }; + + return $macro; + } +} diff --git a/src/head/src/Routing/RouteAttributeParser.php b/src/head/src/Routing/RouteAttributeParser.php new file mode 100644 index 0000000000..d2915fd807 --- /dev/null +++ b/src/head/src/Routing/RouteAttributeParser.php @@ -0,0 +1,192 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Routing; + +use Hypervel\Head\HeadData; +use Hypervel\Head\TagRegistry; +use Hypervel\Routing\Route; +use InvalidArgumentException; + +/** + * @phpstan-type HeadAttributeArray array<mixed, mixed> + */ +class RouteAttributeParser +{ + public const string HEAD = 'head'; + + protected static int $metadataLayer = 0; + + /** + * Normalize variadic head arguments, unwrapping a single array argument. + * + * @param array<int|string, mixed> $arguments + * @return array<mixed, mixed> + */ + public static function arguments(array $arguments): array + { + if (array_key_exists(0, $arguments) && is_array($arguments[0]) && count($arguments) === 1) { + return $arguments[0]; + } + + return $arguments; + } + + /** + * Wrap head attributes in a cache-friendly route metadata layer. + * + * @param array<string, mixed> $attributes + * @return array<string, array<string, array<string, mixed>>> + */ + public static function metadata(array $attributes): array + { + if ($attributes === []) { + return []; + } + + return [ + static::HEAD => [ + 'layer-' . static::$metadataLayer++ => $attributes, + ], + ]; + } + + /** + * Wrap named head arguments in a cache-friendly route metadata layer, + * discarding arguments that were not provided. + * + * @param array<mixed, mixed> $arguments + * @return array<string, array<string, array<string, mixed>>> + */ + public static function metadataFromArguments(array $arguments): array + { + $extensions = $arguments['extensions'] ?? []; + + unset($arguments['extensions']); + + if (is_array($extensions)) { + $arguments += $extensions; + } + + $attributes = []; + + foreach ($arguments as $key => $value) { + if (is_string($key) && ! is_null($value)) { + $attributes[$key] = $value; + } + } + + return static::metadata($attributes); + } + + /** + * Get the head attribute layers stored in the route's metadata. + * + * @return array<int, array<mixed, mixed>> + */ + public static function routeMetadata(Route $route): array + { + $metadata = $route->getMetadata(static::HEAD, []); + + if (! is_array($metadata)) { + return []; + } + + $layers = []; + + foreach ($metadata as $attributes) { + if (is_array($attributes)) { + $layers[] = $attributes; + } + } + + return $layers; + } + + /** + * Apply head attributes on top of the given head data. + * + * @param null|array<mixed, mixed>|HeadData $attributes + */ + public static function apply(HeadData $head, HeadData|array|null $attributes, TagRegistry $registry): HeadData + { + if (is_null($attributes)) { + return $head; + } + + if ($attributes instanceof HeadData) { + return $head->merge($attributes); + } + + return static::fill($head, static::named($attributes), $registry); + } + + /** + * Overlay each named attribute's builder onto a copy of the head data. + * + * @param array<string, mixed> $attributes + */ + protected static function fill(HeadData $head, array $attributes, TagRegistry $registry): HeadData + { + $head = clone $head; + + $routeAttributeKeys = $registry->routeAttributeKeys(); + + foreach ($attributes as $key => $value) { + if (! isset($routeAttributeKeys[$key])) { + throw new InvalidArgumentException(sprintf( + 'Unknown route head attribute [%s]. Supported attributes are: %s.', + $key, + implode(', ', array_keys($routeAttributeKeys)), + )); + } + + $builderClass = $routeAttributeKeys[$key]; + + $builder = $builderClass::fromRouteAttribute($key, $value); + + if (is_null($builder) || ($builder->isEmpty() && ! static::isEmptyRouteAttributeValue($value))) { + throw new InvalidArgumentException(sprintf( + 'Invalid value for route head attribute [%s].', + $key, + )); + } + + $head->overlayBuilder($builder); + } + + return $head; + } + + /** + * Determine whether an attribute intentionally supplies an empty value. + */ + protected static function isEmptyRouteAttributeValue(mixed $value): bool + { + return $value === []; + } + + /** + * Validate that all route head attributes are named. + * + * @param array<mixed, mixed> $attributes + * @return array<string, mixed> + */ + protected static function named(array $attributes): array + { + $named = []; + + foreach ($attributes as $key => $value) { + if (is_string($key)) { + $named[$key] = $value; + + continue; + } + + throw new InvalidArgumentException('Route head attributes must be named.'); + } + + return $named; + } +} diff --git a/src/head/src/Schema/Article.php b/src/head/src/Schema/Article.php new file mode 100644 index 0000000000..5d470038fa --- /dev/null +++ b/src/head/src/Schema/Article.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use DateTimeInterface; +use Hypervel\Head\SchemaType; + +#[SchemaType('Article')] +class Article extends SchemaObject +{ + /** + * Set the article headline. + */ + public function headline(string $headline): static + { + return $this->set('headline', $headline); + } + + /** + * Set the article description. + */ + public function description(string $description): static + { + return $this->set('description', $description); + } + + /** + * Set the article author. + */ + public function author(Person|Organization $author): static + { + return $this->set('author', $author); + } + + /** + * Set the publication date. + */ + public function publishedAt(DateTimeInterface|string $date): static + { + return $this->date('datePublished', $date); + } + + /** + * Set the modification date. + */ + public function modifiedAt(DateTimeInterface|string $date): static + { + return $this->date('dateModified', $date); + } + + /** + * Set the article images. + * + * @param array<int, string>|string $image + */ + public function image(string|array $image): static + { + return $this->set('image', $image); + } +} diff --git a/src/head/src/Schema/BlogPosting.php b/src/head/src/Schema/BlogPosting.php new file mode 100644 index 0000000000..0f36c5b4ca --- /dev/null +++ b/src/head/src/Schema/BlogPosting.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('BlogPosting')] +class BlogPosting extends Article +{ +} diff --git a/src/head/src/Schema/Brand.php b/src/head/src/Schema/Brand.php new file mode 100644 index 0000000000..643ebaaa16 --- /dev/null +++ b/src/head/src/Schema/Brand.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('Brand')] +class Brand extends SchemaObject +{ + /** + * Set the brand name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the brand URL. + */ + public function url(string $url): static + { + return $this->set('url', $url); + } + + /** + * Set the brand logo URL. + */ + public function logo(string $url): static + { + return $this->set('logo', $url); + } +} diff --git a/src/head/src/Schema/Breadcrumbs.php b/src/head/src/Schema/Breadcrumbs.php new file mode 100644 index 0000000000..0c94dbd02d --- /dev/null +++ b/src/head/src/Schema/Breadcrumbs.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('BreadcrumbList')] +class Breadcrumbs extends SchemaObject +{ + /** @var array<int, array{'@type': string, position: int, name: string, item: string}> */ + protected array $items = []; + + /** + * Append a breadcrumb item. + */ + public function item(string $name, string $url): static + { + $this->items[] = [ + '@type' => 'ListItem', + 'position' => count($this->items) + 1, + 'name' => $name, + 'item' => $url, + ]; + + return $this->set('itemListElement', $this->items); + } + + /** + * Append breadcrumb items in order. + * + * @param array<string, string> $items breadcrumb names keyed to their URLs + */ + public function items(array $items): static + { + foreach ($items as $name => $url) { + $this->item($name, $url); + } + + return $this; + } +} diff --git a/src/head/src/Schema/Event.php b/src/head/src/Schema/Event.php new file mode 100644 index 0000000000..b4121f5538 --- /dev/null +++ b/src/head/src/Schema/Event.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use DateTimeInterface; +use Hypervel\Head\SchemaType; + +#[SchemaType('Event')] +class Event extends SchemaObject +{ + /** + * Set the event description. + */ + public function description(string $description): static + { + return $this->set('description', $description); + } + + /** + * Set the time admission begins. + */ + public function doorTime(DateTimeInterface|string $doorTime): static + { + return $this->date('doorTime', $doorTime); + } + + /** + * Set the event end date. + */ + public function endDate(DateTimeInterface|string $endDate): static + { + return $this->date('endDate', $endDate); + } + + /** + * Set the event name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the event offers. + * + * @param array<int, Offer>|Offer $offers + */ + public function offers(Offer|array $offers): static + { + return $this->set('offers', $offers); + } + + /** + * Set the event organizer. + */ + public function organizer(Organization|Person $organizer): static + { + return $this->set('organizer', $organizer); + } + + /** + * Set the event start date. + */ + public function startDate(DateTimeInterface|string $startDate): static + { + return $this->date('startDate', $startDate); + } +} diff --git a/src/head/src/Schema/Faq.php b/src/head/src/Schema/Faq.php new file mode 100644 index 0000000000..2f00f22936 --- /dev/null +++ b/src/head/src/Schema/Faq.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('FAQPage')] +class Faq extends SchemaObject +{ + /** @var array<int, array{'@type': string, name: string, acceptedAnswer: array{'@type': string, text: string}}> */ + protected array $questions = []; + + /** + * Append a question and its accepted answer. + */ + public function question(string $name, string $answer): static + { + $this->questions[] = [ + '@type' => 'Question', + 'name' => $name, + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => $answer, + ], + ]; + + return $this->set('mainEntity', $this->questions); + } + + /** + * Append questions and their answers. + * + * @param array<string, string> $questions questions keyed to their answers + */ + public function questions(array $questions): static + { + foreach ($questions as $name => $answer) { + $this->question($name, $answer); + } + + return $this; + } +} diff --git a/src/head/src/Schema/GenericSchemaObject.php b/src/head/src/Schema/GenericSchemaObject.php new file mode 100644 index 0000000000..0e211a130a --- /dev/null +++ b/src/head/src/Schema/GenericSchemaObject.php @@ -0,0 +1,9 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +class GenericSchemaObject extends SchemaObject +{ +} diff --git a/src/head/src/Schema/Offer.php b/src/head/src/Schema/Offer.php new file mode 100644 index 0000000000..16de825039 --- /dev/null +++ b/src/head/src/Schema/Offer.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\Enums\OfferAvailability; +use Hypervel\Head\SchemaType; + +#[SchemaType('Offer')] +class Offer extends SchemaObject +{ + /** + * Set the offer price. + */ + public function price(float|int|string $price): static + { + return $this->set('price', $price); + } + + /** + * Set the price currency. + */ + public function currency(string $currency): static + { + return $this->set('priceCurrency', $currency); + } + + /** + * Set the offer availability. + */ + public function availability(OfferAvailability $availability): static + { + return $this->set('availability', $availability->url()); + } +} diff --git a/src/head/src/Schema/Organization.php b/src/head/src/Schema/Organization.php new file mode 100644 index 0000000000..04540d3c60 --- /dev/null +++ b/src/head/src/Schema/Organization.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('Organization')] +class Organization extends SchemaObject +{ + /** + * Set the organization name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the organization URL. + */ + public function url(string $url): static + { + return $this->set('url', $url); + } + + /** + * Set the organization logo URL. + */ + public function logo(string $url): static + { + return $this->set('logo', $url); + } +} diff --git a/src/head/src/Schema/Person.php b/src/head/src/Schema/Person.php new file mode 100644 index 0000000000..daf529438e --- /dev/null +++ b/src/head/src/Schema/Person.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('Person')] +class Person extends SchemaObject +{ + /** + * Set the person's name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the person's URL. + */ + public function url(string $url): static + { + return $this->set('url', $url); + } +} diff --git a/src/head/src/Schema/Product.php b/src/head/src/Schema/Product.php new file mode 100644 index 0000000000..dd5d18d7dc --- /dev/null +++ b/src/head/src/Schema/Product.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('Product')] +class Product extends SchemaObject +{ + /** + * Set the product name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the product description. + */ + public function description(string $description): static + { + return $this->set('description', $description); + } + + /** + * Set the product images. + * + * @param array<int, string>|string $image + */ + public function image(string|array $image): static + { + return $this->set('image', $image); + } + + /** + * Set the product stock-keeping unit. + */ + public function sku(string $sku): static + { + return $this->set('sku', $sku); + } + + /** + * Set the product brand. + */ + public function brand(Brand|Organization $brand): static + { + return $this->set('brand', $brand); + } + + /** + * Set the product offers. + * + * @param array<int, Offer>|Offer $offers + */ + public function offers(Offer|array $offers): static + { + return $this->set('offers', $offers); + } +} diff --git a/src/head/src/Schema/SchemaFactory.php b/src/head/src/Schema/SchemaFactory.php new file mode 100644 index 0000000000..311d8b9209 --- /dev/null +++ b/src/head/src/Schema/SchemaFactory.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Support\Str; +use ReflectionClass; + +/** + * @method Article article() + * @method BlogPosting blogPosting() + * @method Event event() + * @method Product product() + * @method Offer offer() + * @method Brand brand() + * @method Breadcrumbs breadcrumbs() + * @method Faq faq() + * @method Organization organization() + * @method Person person() + * @method WebPage webPage() + * @method WebSite webSite() + */ +class SchemaFactory +{ + /** @var array<string, class-string<SchemaObject>> */ + protected array $types = []; + + /** + * Register the built-in schema types. + */ + public function __construct() + { + $this->register(Article::class); + $this->register(BlogPosting::class); + $this->register(Event::class); + $this->register(Product::class); + $this->register(Offer::class); + $this->register(Brand::class); + $this->register(Breadcrumbs::class); + $this->register(Faq::class); + $this->register(Organization::class); + $this->register(Person::class); + $this->register(WebPage::class); + $this->register(WebSite::class); + } + + /** + * Register a first-class schema object type. + * + * Boot-only. Registered types affect every request using this shared factory. + * + * @param class-string<SchemaObject> $class + */ + public function register(string $class): static + { + $this->types[Str::camel((new ReflectionClass($class))->getShortName())] = $class; + + return $this; + } + + /** + * Make a schema object from a schema object class or registered type name. + * + * @template TSchema of SchemaObject + * + * @param class-string<TSchema>|string $type + * @return ($type is class-string<TSchema> ? TSchema : SchemaObject) + */ + public function make(string $type): SchemaObject + { + if (is_subclass_of($type, SchemaObject::class)) { + return new $type; + } + + $class = $this->types[Str::camel($type)] ?? null; + + return $class ? new $class : new GenericSchemaObject(Str::studly($type)); + } + + /** + * Dynamically make a schema object for the called type name. + * + * @param array<int, mixed> $parameters + */ + public function __call(string $method, array $parameters): SchemaObject + { + return $this->make($method); + } +} diff --git a/src/head/src/Schema/SchemaObject.php b/src/head/src/Schema/SchemaObject.php new file mode 100644 index 0000000000..c4e0e8f94b --- /dev/null +++ b/src/head/src/Schema/SchemaObject.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use BadMethodCallException; +use DateTimeInterface; +use Hypervel\Contracts\Container\Transient; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Head\SchemaType; +use Hypervel\Support\Str; +use JsonSerializable; +use ReflectionClass; + +/** + * @implements Arrayable<array<string, mixed>> + */ +abstract class SchemaObject implements Arrayable, JsonSerializable, Transient +{ + protected string $type; + + /** @var array<string, mixed> */ + protected array $properties = []; + + /** @var array<class-string<static>, string> */ + protected static array $types = []; + + /** + * Create a schema with an explicit or inferred type. + */ + public function __construct(?string $type = null) + { + $this->type = $type ?? $this->typeFromAttribute(); + } + + /** + * Set a schema property, coercing nested schema objects and dates. + */ + public function set(string $property, mixed $value): static + { + $this->properties[$property] = $this->coerce($value); + + return $this; + } + + /** + * Set a date property using an ISO 8601 value. + */ + public function date(string $property, DateTimeInterface|string $date): static + { + return $this->set($property, $date instanceof DateTimeInterface ? $date->format(DateTimeInterface::ATOM) : $date); + } + + /** + * Get the schema type and properties. + * + * @return array<string, mixed> + */ + public function toArray(): array + { + return ['@type' => $this->type] + $this->properties; + } + + /** + * Get the schema with its JSON-LD context. + * + * @return array<string, mixed> + */ + public function toJsonLd(): array + { + return ['@context' => 'https://schema.org'] + $this->toArray(); + } + + /** + * Get the schema's JSON representation. + * + * @return array<string, mixed> + */ + public function jsonSerialize(): array + { + return $this->toJsonLd(); + } + + /** + * Dynamically set the schema property named by the called method. + * + * @param array<int, mixed> $parameters + */ + public function __call(string $method, array $parameters): static + { + if (count($parameters) > 1) { + throw new BadMethodCallException(sprintf( + 'Magic schema property setters accept a single value, and [%s] does not define a [%s] method.', + static::class, + $method, + )); + } + + return $this->set($method, $parameters[0] ?? true); + } + + /** + * Normalize nested schemas and dates into their serialized values. + */ + protected function coerce(mixed $value): mixed + { + if ($value instanceof SchemaObject) { + return $value->toArray(); + } + + if ($value instanceof DateTimeInterface) { + return $value->format(DateTimeInterface::ATOM); + } + + if (is_array($value)) { + return array_map($this->coerce(...), $value); + } + + return $value; + } + + /** + * Resolve the schema.org type from the SchemaType attribute or the class name. + */ + protected function typeFromAttribute(): string + { + if (isset(static::$types[static::class])) { + return static::$types[static::class]; + } + + $reflection = new ReflectionClass($this); + $attributes = $reflection->getAttributes(SchemaType::class); + + return static::$types[static::class] = $attributes === [] + ? Str::headline($reflection->getShortName()) + : $attributes[0]->newInstance()->name; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$types = []; + } +} diff --git a/src/head/src/Schema/SchemaValidator.php b/src/head/src/Schema/SchemaValidator.php new file mode 100644 index 0000000000..5fe3928f2f --- /dev/null +++ b/src/head/src/Schema/SchemaValidator.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Contracts\Foundation\Application; +use Hypervel\Head\Exceptions\InvalidSchema; +use Psr\Log\LoggerInterface; + +class SchemaValidator +{ + /** + * Create the schema validator. + */ + public function __construct(protected Application $app) + { + } + + /** + * Validate a schema, logging invalid definitions in production. + * + * @param array<string, mixed> $schema + */ + public function validate(array $schema): void + { + $message = match (true) { + ($schema['@context'] ?? null) !== 'https://schema.org' => 'JSON-LD schemas must use the https://schema.org context.', + ! isset($schema['@type']) || $schema['@type'] === '' => 'JSON-LD schemas must define an @type.', + $this->containsEmptyValue($schema) => 'JSON-LD schemas may not contain null or empty string values.', + default => null, + }; + + if (is_null($message)) { + return; + } + + if ($this->app->environment('production')) { + if ($this->app->bound(LoggerInterface::class)) { + $this->app->make(LoggerInterface::class)->warning($message, ['schema' => $schema]); + } + + return; + } + + throw new InvalidSchema($message); + } + + /** + * Determine whether any nested value is null or an empty string. + * + * @param array<mixed> $values + */ + protected function containsEmptyValue(array $values): bool + { + foreach ($values as $value) { + if ($value === null || $value === '') { + return true; + } + + if (is_array($value) && $this->containsEmptyValue($value)) { + return true; + } + } + + return false; + } +} diff --git a/src/head/src/Schema/WebPage.php b/src/head/src/Schema/WebPage.php new file mode 100644 index 0000000000..9f43d0dd88 --- /dev/null +++ b/src/head/src/Schema/WebPage.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('WebPage')] +class WebPage extends SchemaObject +{ + /** + * Set the page name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the page description. + */ + public function description(string $description): static + { + return $this->set('description', $description); + } + + /** + * Set the page URL. + */ + public function url(string $url): static + { + return $this->set('url', $url); + } +} diff --git a/src/head/src/Schema/WebSite.php b/src/head/src/Schema/WebSite.php new file mode 100644 index 0000000000..2446f657e4 --- /dev/null +++ b/src/head/src/Schema/WebSite.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('WebSite')] +class WebSite extends SchemaObject +{ + /** + * Set the website name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the website URL. + */ + public function url(string $url): static + { + return $this->set('url', $url); + } +} diff --git a/src/head/src/SchemaType.php b/src/head/src/SchemaType.php new file mode 100644 index 0000000000..781ea3512c --- /dev/null +++ b/src/head/src/SchemaType.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Attribute; + +#[Attribute(Attribute::TARGET_CLASS)] +class SchemaType +{ + /** + * Define the schema.org type for a schema class. + */ + public function __construct(public string $name) + { + } +} diff --git a/src/head/src/TagRegistry.php b/src/head/src/TagRegistry.php new file mode 100644 index 0000000000..fa91363406 --- /dev/null +++ b/src/head/src/TagRegistry.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Hypervel\Head\Tags\AlternateLinks; +use Hypervel\Head\Tags\Canonical; +use Hypervel\Head\Tags\Description; +use Hypervel\Head\Tags\FeedLinks; +use Hypervel\Head\Tags\GenericLinks; +use Hypervel\Head\Tags\MetaTags; +use Hypervel\Head\Tags\OpenGraph; +use Hypervel\Head\Tags\PaginationLinks; +use Hypervel\Head\Tags\PerformanceLinks; +use Hypervel\Head\Tags\Robots; +use Hypervel\Head\Tags\Schemas; +use Hypervel\Head\Tags\TagBuilder; +use Hypervel\Head\Tags\Title; +use Hypervel\Head\Tags\Twitter; +use InvalidArgumentException; + +class TagRegistry +{ + /** @var array<int, class-string<TagBuilder>> */ + protected array $extensions = []; + + /** @var null|array<string, class-string<TagBuilder>> */ + protected ?array $routeAttributeKeys = null; + + /** + * Get the tag builders rendered for every head, in render order. + * + * @return array<int, class-string<TagBuilder>> + */ + public function builders(): array + { + return [...$this->defaults(), ...$this->extensions]; + } + + /** + * Register a custom tag builder. + * + * Boot-only. Registered builders affect every request using this shared registry. + * + * @param class-string $builder + */ + public function extend(string $builder): static + { + if (! is_subclass_of($builder, TagBuilder::class)) { + throw new InvalidArgumentException('Head tag extensions must extend ' . TagBuilder::class . '.'); + } + + if (in_array($builder, $this->defaults(), true)) { + throw new InvalidArgumentException('Built-in head tag builders are already registered.'); + } + + $duplicates = array_values(array_intersect($builder::routeAttributeKeys(), array_keys($this->routeAttributeKeys()))); + + if ($duplicates !== []) { + throw new InvalidArgumentException(sprintf( + 'Head tag extensions may not register route attributes already registered by another builder: %s.', + implode(', ', $duplicates), + )); + } + + if (! in_array($builder, $this->extensions, true)) { + $this->extensions[] = $builder; + $this->routeAttributeKeys = null; + } + + return $this; + } + + /** + * Map each supported route attribute key to its tag builder. + * + * @return array<string, class-string<TagBuilder>> + */ + public function routeAttributeKeys(): array + { + if ($this->routeAttributeKeys !== null) { + return $this->routeAttributeKeys; + } + + $keys = []; + + foreach ($this->builders() as $builder) { + foreach ($builder::routeAttributeKeys() as $key) { + $keys[$key] = $builder; + } + } + + return $this->routeAttributeKeys = $keys; + } + + /** + * Get the built-in tag builders in render order. + * + * @return array<int, class-string<TagBuilder>> + */ + protected function defaults(): array + { + return [ + Title::class, + Description::class, + Canonical::class, + Robots::class, + OpenGraph::class, + Twitter::class, + PaginationLinks::class, + AlternateLinks::class, + FeedLinks::class, + PerformanceLinks::class, + MetaTags::class, + GenericLinks::class, + Schemas::class, + ]; + } +} diff --git a/src/head/src/Tags/AlternateLinks.php b/src/head/src/Tags/AlternateLinks.php new file mode 100644 index 0000000000..e15e795d2c --- /dev/null +++ b/src/head/src/Tags/AlternateLinks.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +/** + * @phpstan-consistent-constructor + */ +class AlternateLinks extends GroupedTagBuilder +{ + /** + * Create the localized alternate links. + * + * @param array<string, string> $links + */ + public function __construct(protected array $links = []) + { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'alternates'; + } + + /** + * Get the key used in the head array. + */ + public static function headArrayKey(): string + { + return 'links.alternates'; + } + + /** + * Build alternate links from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key !== 'alternates' || ! is_array($value)) { + return null; + } + + $links = new self; + + foreach ($value as $locale => $href) { + if (is_string($href)) { + $links->link((string) $locale, $href); + } + } + + return $links; + } + + /** + * Set the alternate link for a locale. + */ + public function link(string $locale, string $href): static + { + $this->links[$locale] = $href; + + return $this; + } + + /** + * Overlay these locales onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static(array_replace($base->links, $this->links)); + } + + /** + * Determine whether no alternate links are defined. + */ + public function isEmpty(): bool + { + return $this->links === []; + } + + /** + * Get the alternate links as an array. + * + * @return array<string, string> + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->links; + } + + /** + * Render the localized alternate link tags. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return array_map( + fn (string $hreflang, string $href): string => $tags->linkWithAttributes('alternate', ['hreflang' => $hreflang, 'href' => $href], 'alternate:' . $hreflang), + array_keys($this->links), + $this->links, + ); + } +} diff --git a/src/head/src/Tags/Canonical.php b/src/head/src/Tags/Canonical.php new file mode 100644 index 0000000000..cb0bd2dcc6 --- /dev/null +++ b/src/head/src/Tags/Canonical.php @@ -0,0 +1,216 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Closure; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; +use Hypervel\Http\Request; + +/** + * @phpstan-consistent-constructor + */ +class Canonical extends TagBuilder +{ + /** @var null|(Closure(): ?string) */ + protected static ?Closure $originResolver = null; + + /** + * Create a canonical URL definition. + * + * @param string $mode either "url" for an explicit URL or "auto" to resolve from the request + */ + public function __construct( + protected string $mode, + protected ?string $url = null, + protected ?bool $forceHttps = null, + protected ?bool $trailingSlash = null, + ) { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'canonical'; + } + + /** + * Use a null $url to resolve the canonical URL from the current request. + */ + public static function make(?string $url = null, ?bool $forceHttps = null, ?bool $trailingSlash = null): self + { + return new self(is_null($url) ? 'auto' : 'url', $url, $forceHttps, $trailingSlash); + } + + /** + * Build a canonical URL from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key !== 'canonical') { + return null; + } + + return match (true) { + is_array($value) => self::fromRouteAttributeArray($value), + is_string($value) => self::make($value), + $value === true || is_null($value) => self::make(), + default => null, + }; + } + + /** + * Overlay this URL while inheriting unspecified normalization options. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static( + $this->mode, + $this->url ?? $base->url, + $this->forceHttps ?? $base->forceHttps, + $this->trailingSlash ?? $base->trailingSlash, + ); + } + + /** + * Canonical builders always carry a resolution mode, so they are never empty. + */ + public function isEmpty(): bool + { + return false; + } + + /** + * Resolve and normalize the canonical URL. + */ + public function render(?Request $request): ?string + { + $url = match ($this->mode) { + 'auto' => $request?->url(), + 'url' => $this->url, + default => null, + }; + + if (is_null($url)) { + return null; + } + + return $this->normalizeUrl($url, $request, $this->forceHttps ?? true, $this->trailingSlash ?? false); + } + + /** + * Get the resolved canonical URL for the head array. + */ + public function toHeadArray(ResolvedHead $head): ?string + { + return $this->render($head->request()); + } + + /** + * Render the canonical link tag. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return ($canonical = $this->render($head->request())) ? [$tags->link('canonical', $canonical, 'canonical')] : []; + } + + /** + * Build a canonical URL from named route options. + * + * @param array<mixed, mixed> $attributes + */ + private static function fromRouteAttributeArray(array $attributes): ?self + { + if (array_key_exists('none', $attributes) || ($attributes['value'] ?? null) === false) { + return null; + } + + $value = self::routeAttributeUrl($attributes); + + return is_string($value) || is_null($value) + ? self::make( + $value, + forceHttps: self::bool($attributes['forceHttps'] ?? null), + trailingSlash: self::bool($attributes['trailingSlash'] ?? null), + ) + : null; + } + + /** + * Get the explicit URL or automatic mode from route options. + * + * @param array<mixed, mixed> $attributes + */ + private static function routeAttributeUrl(array $attributes): mixed + { + $value = $attributes['value'] ?? null; + + return $value === true || self::bool($attributes['auto'] ?? null) === true + ? null + : $value; + } + + /** + * Register a resolver for automatic and relative canonical origins. + * + * Boot-only. The callback persists in a static property and affects every request in the worker. + * + * @param null|(Closure(): ?string) $resolver + */ + public static function resolveOriginUsing(?Closure $resolver): void + { + static::$originResolver = $resolver; + } + + /** + * Normalize the origin and path without changing the query or fragment. + */ + protected function normalizeUrl(string $url, ?Request $request, bool $forceHttps, bool $trailingSlash): string + { + $relative = ! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://'); + + if ($relative || $this->mode === 'auto') { + $origin = static::$originResolver !== null ? (static::$originResolver)() : null; + + if ($relative) { + $url = rtrim($origin ?? $request?->getSchemeAndHttpHost() ?? '', '/') . '/' . ltrim($url, '/'); + } elseif ($origin !== null && $request !== null) { + $url = rtrim($origin, '/') . substr($url, strlen($request->getSchemeAndHttpHost())); + } + } + + if ($forceHttps) { + $url = preg_replace('/^http:\/\//', 'https://', $url) ?? $url; + } + + // Slashes belong to the path, never after a query string or fragment. + $suffixOffset = strcspn($url, '?#'); + $suffix = substr($url, $suffixOffset); + $url = substr($url, 0, $suffixOffset); + $path = parse_url($url, PHP_URL_PATH); + + if ($trailingSlash || $path === null || $path === false || $path === '' || $path === '/') { + return rtrim($url, '/') . '/' . $suffix; + } + + return rtrim($url, '/') . $suffix; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$originResolver = null; + } +} diff --git a/src/head/src/Tags/Description.php b/src/head/src/Tags/Description.php new file mode 100644 index 0000000000..522f766694 --- /dev/null +++ b/src/head/src/Tags/Description.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +class Description extends StringTagBuilder +{ + /** + * Get the builder key. + */ + public static function key(): string + { + return 'description'; + } + + /** + * Render the description meta tag. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + $description = $this->render(); + + return $description !== null && $description !== '' + ? [$tags->meta('name', 'description', $description, 'description')] + : []; + } +} diff --git a/src/head/src/Tags/FeedLinks.php b/src/head/src/Tags/FeedLinks.php new file mode 100644 index 0000000000..7d639a5684 --- /dev/null +++ b/src/head/src/Tags/FeedLinks.php @@ -0,0 +1,193 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +/** + * @phpstan-consistent-constructor + * + * @phpstan-type FeedAttributes array{href: string, title: string, type: string} + */ +class FeedLinks extends GroupedTagBuilder +{ + /** + * Create the feed links. + * + * @param array<string, FeedAttributes> $feeds + */ + public function __construct(protected array $feeds = []) + { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'feeds'; + } + + /** + * Get the key used in the head array. + */ + public static function headArrayKey(): string + { + return 'links.feeds'; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return ['feed']; + } + + /** + * Build feed links from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key !== 'feed' || ! is_array($value)) { + return null; + } + + $links = new self; + + foreach (self::feedAttributes($value) as $href => $feed) { + if (! $links->addRouteAttributeFeed($href, $feed)) { + return null; + } + } + + return $links; + } + + /** + * Add or replace a feed link by URL. + */ + public function feed(string $href, string $title, string $type = 'rss'): static + { + $this->feeds[$href] = [ + 'href' => $href, + 'title' => $title, + 'type' => $type, + ]; + + return $this; + } + + /** + * Overlay these feeds onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static(array_replace($base->feeds, $this->feeds)); + } + + /** + * Determine whether no feeds are defined. + */ + public function isEmpty(): bool + { + return $this->feeds === []; + } + + /** + * Get the feed links for the head array. + * + * @return array<int, FeedAttributes> + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->headArray(); + } + + /** + * Render the feed link tags. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return array_map(function (array $feed) use ($tags): string { + $type = $feed['type'] === 'atom' ? 'application/atom+xml' : 'application/rss+xml'; + + return $tags->linkWithAttributes('alternate', [ + 'type' => $type, + 'title' => $feed['title'], + 'href' => $feed['href'], + ], $tags->stableKey('feed', $feed['href'])); + }, $this->headArray()); + } + + /** + * Get the ordered feed definitions. + * + * @return array<int, FeedAttributes> + */ + protected function headArray(): array + { + return array_values($this->feeds); + } + + /** + * Add a valid feed definition from route metadata. + */ + private function addRouteAttributeFeed(mixed $href, mixed $feed): bool + { + if (is_string($href) && is_string($feed)) { + $this->feed($href, $feed); + + return true; + } + + if (! is_array($feed) || ! is_string($feed['title'] ?? null) || is_null($url = self::routeAttributeHref($href, $feed))) { + return false; + } + + $this->feed($url, $feed['title'], self::string($feed['type'] ?? null) ?? 'rss'); + + return true; + } + + /** + * Normalize a route attribute value into a list of feed definitions, + * treating an attribute map containing any single-feed key as one feed. + * + * @param array<mixed, mixed> $value + * @return array<mixed, mixed> + */ + private static function feedAttributes(array $value): array + { + if (array_is_list($value)) { + return $value; + } + + if (array_key_exists('href', $value) || array_key_exists('title', $value) || array_key_exists('type', $value)) { + return [$value]; + } + + return $value; + } + + /** + * Resolve a feed URL from an explicit attribute or its map key. + * + * @param array<mixed, mixed> $feed + */ + private static function routeAttributeHref(mixed $href, array $feed): ?string + { + return self::string($feed['href'] ?? null) ?? self::string($href); + } +} diff --git a/src/head/src/Tags/GenericLinks.php b/src/head/src/Tags/GenericLinks.php new file mode 100644 index 0000000000..21df9cd637 --- /dev/null +++ b/src/head/src/Tags/GenericLinks.php @@ -0,0 +1,263 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use BackedEnum; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +/** + * @phpstan-consistent-constructor + * + * @phpstan-type LinkAttributes array{rel: string, href: string, attributes: array<string, bool|float|int|string|null>} + */ +class GenericLinks extends GroupedTagBuilder +{ + /** + * Create the link definitions. + * + * @param array<string, LinkAttributes> $links + */ + public function __construct(protected array $links = []) + { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'links'; + } + + /** + * Get the key used in the head array. + */ + public static function headArrayKey(): string + { + return 'links.generic'; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return ['link', 'icon', 'favicon', 'appleTouchIcon', 'maskIcon', 'manifest', 'appleTouchStartupImage']; + } + + /** + * Build links from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key !== 'link') { + return self::fromAliasRouteAttribute($key, $value); + } + + if (! is_array($value)) { + return null; + } + + $links = new self; + + foreach (self::items($value) as $link) { + $links->addRouteAttributeLink($link); + } + + return $links; + } + + /** + * Build links from a named link alias. + */ + protected static function fromAliasRouteAttribute(string $key, mixed $value): ?self + { + if (! in_array($key, ['icon', 'favicon', 'appleTouchIcon', 'maskIcon', 'manifest', 'appleTouchStartupImage'], true)) { + return null; + } + + $links = new self; + + foreach (self::items($value) as $link) { + $links->addAliasRouteAttributeLink($key, $link); + } + + return $links; + } + + /** + * Add or replace a link definition. + * + * @param array<string, null|BackedEnum|bool|float|int|string> $attributes + */ + public function link(string $rel, string $href, array $attributes = []): static + { + unset($attributes['rel'], $attributes['href']); + $attributes = self::attributes($attributes); + + // A document has one manifest; other links may coexist at different URLs. + $key = $rel === 'manifest' ? $rel : $rel . ' ' . $href; + + $this->links[$key] = [ + 'rel' => $rel, + 'href' => $href, + 'attributes' => $attributes, + ]; + + return $this; + } + + /** + * Overlay these links onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static(array_replace($base->links, $this->links)); + } + + /** + * Determine whether no links are defined. + */ + public function isEmpty(): bool + { + return $this->links === []; + } + + /** + * Get the links for the head array. + * + * @return array<int, LinkAttributes> + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->headArray(); + } + + /** + * Render the link tags with stable ownership keys. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return array_map(function (array $link) use ($tags): string { + // Keep the manifest's identity when its URL changes during Inertia navigation. + $key = $link['rel'] === 'manifest' ? 'link:manifest' : $tags->stableKey('link:' . $link['rel'], $link['href']); + + return $tags->linkWithAttributes($link['rel'], ['href' => $link['href'], ...$link['attributes']], $key); + }, $this->headArray()); + } + + /** + * Get the ordered link definitions. + * + * @return array<int, LinkAttributes> + */ + protected function headArray(): array + { + return array_values($this->links); + } + + /** + * Add a valid link from route metadata. + */ + private function addRouteAttributeLink(mixed $link): void + { + if (! is_array($link) || ! is_string($link['rel'] ?? null) || ! is_string($link['href'] ?? null)) { + return; + } + + $attributes = self::named($link); + unset($attributes['rel'], $attributes['href']); + + $this->link($link['rel'], $link['href'], self::attributes($attributes)); + } + + /** + * Add a link alias from route metadata. + */ + private function addAliasRouteAttributeLink(string $key, mixed $link): void + { + $attributes = is_array($link) ? self::named($link) : []; + $href = self::routeAttributeHref($link, $attributes, $key === 'manifest' ? '/site.webmanifest' : null); + + if (is_null($href)) { + return; + } + + match ($key) { + 'icon', 'favicon' => $this->link('icon', $href, self::attributes([ + 'type' => self::stringOrBackedEnum($attributes['type'] ?? null), + 'sizes' => $attributes['sizes'] ?? null, + 'media' => $attributes['media'] ?? null, + ])), + 'appleTouchIcon' => $this->link('apple-touch-icon', $href, self::attributes([ + 'sizes' => $attributes['sizes'] ?? null, + ])), + 'maskIcon' => $this->link('mask-icon', $href, self::attributes([ + 'color' => $attributes['color'] ?? null, + ])), + 'manifest' => $this->link('manifest', $href, self::attributes([ + 'crossorigin' => $attributes['crossorigin'] ?? null, + ])), + 'appleTouchStartupImage' => $this->link('apple-touch-startup-image', $href, self::attributes([ + 'media' => $attributes['media'] ?? null, + ])), + default => null, + }; + } + + /** + * Resolve a link URL from its route attribute value. + * + * @param array<string, mixed> $attributes + */ + private static function routeAttributeHref(mixed $value, array $attributes, ?string $default = null): ?string + { + if (is_string($value)) { + return $value; + } + + if (is_string($attributes['href'] ?? null)) { + return $attributes['href']; + } + + if ($value === true || is_array($value)) { + return $default; + } + + return null; + } + + /** + * Normalize scalar and enum link attributes. + * + * @param array<string, mixed> $values + * @return array<string, null|bool|float|int|string> + */ + protected static function attributes(array $values): array + { + $attributes = []; + + foreach ($values as $key => $value) { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (is_null($value) || is_bool($value) || is_float($value) || is_int($value) || is_string($value)) { + $attributes[$key] = $value; + } + } + + return $attributes; + } +} diff --git a/src/head/src/Tags/GroupedTagBuilder.php b/src/head/src/Tags/GroupedTagBuilder.php new file mode 100644 index 0000000000..c177767088 --- /dev/null +++ b/src/head/src/Tags/GroupedTagBuilder.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +abstract class GroupedTagBuilder extends TagBuilder +{ + /** + * Grouped tag builders are represented as an empty array when absent + * from the Head::toArray() result. + */ + public static function headArrayDefault(): mixed + { + return []; + } +} diff --git a/src/head/src/Tags/MetaTags.php b/src/head/src/Tags/MetaTags.php new file mode 100644 index 0000000000..fead2b092c --- /dev/null +++ b/src/head/src/Tags/MetaTags.php @@ -0,0 +1,228 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Enums\Media; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; +use Hypervel\Support\Str; + +/** + * @phpstan-consistent-constructor + * + * @phpstan-type MetaAttributes array{key: string, content: string, property?: bool|null, media?: string|null} + */ +class MetaTags extends GroupedTagBuilder +{ + /** + * Create the meta tag definitions. + * + * @param array<string, MetaAttributes> $tags + */ + public function __construct(protected array $tags = []) + { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'meta'; + } + + /** + * Build meta tags from a route attribute or alias. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key === 'webAppCapable' && is_bool($value)) { + return (new self)->tag(self::aliases()[$key], $value ? 'yes' : 'no'); + } + + if (array_key_exists($key, self::aliases()) && is_string($value)) { + return (new self)->tag(self::aliases()[$key], $value); + } + + if ($key !== 'meta' || ! is_array($value)) { + return null; + } + + $tags = new self; + + foreach ($value as $metaKey => $meta) { + if (is_string($metaKey) && is_string($meta)) { + $tags->tag($metaKey, $meta); + } + + if (is_array($meta) && is_string($meta['key'] ?? null) && is_string($meta['content'] ?? null)) { + $tags->tag( + $meta['key'], + $meta['content'], + property: self::bool($meta['property'] ?? null), + media: self::stringOrBackedEnum($meta['media'] ?? null), + ); + } + } + + return $tags; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return ['meta', ...array_keys(self::aliases())]; + } + + /** + * Add or replace a meta tag for its attribute, key, and media. + */ + public function tag(string $key, string $content, ?bool $property = null, Media|string|null $media = null): static + { + $media = $media instanceof Media ? $media->value : $media; + + $this->tags[$this->tagKey($key, $property, $media)] = array_filter([ + 'key' => $key, + 'content' => $content, + 'property' => $property, + 'media' => $media, + ], fn (bool|string|null $value): bool => ! is_null($value)); + + return $this; + } + + /** + * Overlay these meta tags onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static(array_replace($base->tags, $this->tags)); + } + + /** + * Determine whether no meta tags are defined. + */ + public function isEmpty(): bool + { + return $this->tags === []; + } + + /** + * Get the meta tags for the head array. + * + * @return array<int, MetaAttributes> + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->headArray(); + } + + /** + * Render the meta tags. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return array_map(function (array $meta) use ($tags): string { + $attribute = $this->resolveAttribute($meta['key'], $meta['property'] ?? null); + + return $tags->metaWithAttributes($attribute, $meta['key'], [ + 'content' => $meta['content'], + 'media' => $meta['media'] ?? null, + ], $this->inertiaKey($meta)); + }, $this->headArray()); + } + + /** + * Get the ordered meta tag definitions. + * + * @return array<int, MetaAttributes> + */ + protected function headArray(): array + { + return array_values($this->tags); + } + + /** + * Determine whether the key belongs to an RDFa namespace. + */ + protected function isRdfaProperty(string $key): bool + { + return Str::startsWith($key, ['og:', 'article:', 'book:', 'profile:', 'music:', 'video:', 'fb:', 'product:']); + } + + /** + * Resolve the HTML attribute a meta tag is keyed under, honoring an explicit + * property flag and falling back to RDFa namespace detection. + */ + protected function resolveAttribute(string $key, ?bool $property): string + { + return ($property ?? $this->isRdfaProperty($key)) ? 'property' : 'name'; + } + + /** + * Get the identity a meta tag is deduplicated by: attribute, key, and media query. + */ + protected function tagKey(string $key, ?bool $property, ?string $media): string + { + return implode('|', [ + $this->resolveAttribute($key, $property), + $key, + $media ?? '', + ]); + } + + /** + * Get the stable Inertia ownership key for a meta tag. + * + * @param MetaAttributes $meta + */ + protected function inertiaKey(array $meta): string + { + if (! isset($meta['media'])) { + return self::inertiaAliases()[$meta['key']] ?? $meta['key']; + } + + return 'meta:' . substr(hash('xxh128', $this->tagKey($meta['key'], $meta['property'] ?? null, $meta['media'])), 0, 16); + } + + /** + * Get the route attribute aliases for meta tag names. + * + * @return array<string, string> + */ + protected static function aliases(): array + { + return [ + 'themeColor' => 'theme-color', + 'applicationName' => 'application-name', + 'colorScheme' => 'color-scheme', + 'referrer' => 'referrer', + 'viewport' => 'viewport', + 'appleWebAppTitle' => 'apple-mobile-web-app-title', + 'webAppCapable' => 'mobile-web-app-capable', + 'appleWebAppStatusBarStyle' => 'apple-mobile-web-app-status-bar-style', + ]; + } + + /** + * Get the Inertia ownership aliases for meta tag names. + * + * @return array<string, string> + */ + protected static function inertiaAliases(): array + { + return array_flip(self::aliases()); + } +} diff --git a/src/head/src/Tags/OpenGraph.php b/src/head/src/Tags/OpenGraph.php new file mode 100644 index 0000000000..387d22c4e1 --- /dev/null +++ b/src/head/src/Tags/OpenGraph.php @@ -0,0 +1,488 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Enums\ImageType; +use Hypervel\Head\Enums\OgType; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +/** + * @phpstan-consistent-constructor + * + * @phpstan-type MediaAttributes array{url: string, alt?: string|null, width?: int|null, height?: int|null, type?: string|null, secureUrl?: string|null} + */ +class OpenGraph extends GroupedTagBuilder +{ + /** + * Create the Open Graph properties and media. + * + * @param array<string, string> $properties + * @param array<string, MediaAttributes> $images + * @param array<string, MediaAttributes> $videos + * @param array<string, MediaAttributes> $audios + */ + public function __construct( + protected array $properties = [], + protected array $images = [], + protected array $videos = [], + protected array $audios = [], + ) { + } + + protected bool $imagesAreDefaults = false; + + protected bool $videosAreDefaults = false; + + protected bool $audiosAreDefaults = false; + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'openGraph'; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return ['og', 'ogImage', 'ogVideo', 'ogAudio']; + } + + /** + * Create Open Graph metadata from named values. + */ + public static function make( + OgType|string|null $type = null, + ?string $title = null, + ?string $description = null, + ?string $url = null, + ?string $image = null, + ?string $video = null, + ?string $audio = null, + ?string $siteName = null, + ?string $locale = null, + ?string $determiner = null, + ): self { + $openGraph = new self; + + $openGraph->set([ + 'type' => $type instanceof OgType ? $type->value : $type, + 'title' => $title, + 'description' => $description, + 'url' => $url, + 'site_name' => $siteName, + 'locale' => $locale, + 'determiner' => $determiner, + ]); + + if (! is_null($image)) { + $openGraph->image($image); + } + + if (! is_null($video)) { + $openGraph->video($video); + } + + if (! is_null($audio)) { + $openGraph->audio($audio); + } + + return $openGraph; + } + + /** + * Build Open Graph metadata from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + return match ($key) { + 'og' => is_array($value) ? self::fromAttributes($value) : null, + 'ogImage' => self::fromImageAttributes($value), + 'ogVideo' => self::fromVideoAttributes($value), + 'ogAudio' => self::fromAudioAttributes($value), + default => null, + }; + } + + /** + * Create Open Graph metadata from an attribute map. + * + * @param array<mixed, mixed> $values + */ + public static function fromAttributes(array $values): self + { + return self::make( + type: self::ogType($values['type'] ?? null), + title: self::string($values['title'] ?? null), + description: self::string($values['description'] ?? null), + url: self::string($values['url'] ?? null), + image: self::string($values['image'] ?? null), + video: self::string($values['video'] ?? null), + audio: self::string($values['audio'] ?? null), + siteName: self::string($values['siteName'] ?? null), + locale: self::string($values['locale'] ?? null), + determiner: self::string($values['determiner'] ?? null), + ); + } + + /** + * Build Open Graph images from route values. + */ + public static function fromImageAttributes(mixed $images): self + { + $openGraph = new self; + + foreach (self::items($images) as $image) { + if (is_string($image)) { + $openGraph->image($image); + } + + if (is_array($image) && is_string($image['url'] ?? null)) { + $openGraph->image( + $image['url'], + alt: self::string($image['alt'] ?? null), + width: self::int($image['width'] ?? null), + height: self::int($image['height'] ?? null), + type: self::imageType($image['type'] ?? null), + secureUrl: self::string($image['secureUrl'] ?? null), + ); + } + } + + return $openGraph; + } + + /** + * Build Open Graph videos from route values. + */ + public static function fromVideoAttributes(mixed $videos): self + { + $openGraph = new self; + + foreach (self::items($videos) as $video) { + if (is_string($video)) { + $openGraph->video($video); + } + + if (is_array($video) && is_string($video['url'] ?? null)) { + $openGraph->video( + $video['url'], + alt: self::string($video['alt'] ?? null), + width: self::int($video['width'] ?? null), + height: self::int($video['height'] ?? null), + type: self::string($video['type'] ?? null), + secureUrl: self::string($video['secureUrl'] ?? null), + ); + } + } + + return $openGraph; + } + + /** + * Build Open Graph audio from route values. + */ + public static function fromAudioAttributes(mixed $audios): self + { + $openGraph = new self; + + foreach (self::items($audios) as $audio) { + if (is_string($audio)) { + $openGraph->audio($audio); + } + + if (is_array($audio) && is_string($audio['url'] ?? null)) { + $openGraph->audio( + $audio['url'], + type: self::string($audio['type'] ?? null), + secureUrl: self::string($audio['secureUrl'] ?? null), + ); + } + } + + return $openGraph; + } + + /** + * Add or replace an image by URL. + */ + public function image( + string $url, + ?string $alt = null, + ?int $width = null, + ?int $height = null, + ImageType|string|null $type = null, + ?string $secureUrl = null, + ): static { + $this->images[$url] = self::media($url, $alt, $width, $height, self::imageType($type), $secureUrl); + + return $this; + } + + /** + * Add or replace a video by URL. + */ + public function video( + string $url, + ?string $alt = null, + ?int $width = null, + ?int $height = null, + ?string $type = null, + ?string $secureUrl = null, + ): static { + $this->videos[$url] = self::media($url, $alt, $width, $height, $type, $secureUrl); + + return $this; + } + + /** + * Add or replace audio by URL. + */ + public function audio(string $url, ?string $type = null, ?string $secureUrl = null): static + { + $this->audios[$url] = self::media($url, type: $type, secureUrl: $secureUrl); + + return $this; + } + + /** + * Overlay properties and media onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + $overlaid = new static( + array_replace($base->properties, $this->properties), + $this->overlayMedia($base->images, $this->images, $base->imagesAreDefaults), + $this->overlayMedia($base->videos, $this->videos, $base->videosAreDefaults), + $this->overlayMedia($base->audios, $this->audios, $base->audiosAreDefaults), + ); + + // A page image must not turn inherited default video or audio into page media. + $overlaid->imagesAreDefaults = $this->images === [] ? $base->imagesAreDefaults : $this->imagesAreDefaults; + $overlaid->videosAreDefaults = $this->videos === [] ? $base->videosAreDefaults : $this->videosAreDefaults; + $overlaid->audiosAreDefaults = $this->audios === [] ? $base->audiosAreDefaults : $this->audiosAreDefaults; + + return $overlaid; + } + + /** + * Mark all media as fallback values for higher layers. + */ + public function asDefaults(): static + { + $defaults = clone $this; + $defaults->imagesAreDefaults = true; + $defaults->videosAreDefaults = true; + $defaults->audiosAreDefaults = true; + + return $defaults; + } + + /** + * Media inherited from the defaults layer is a fallback, so media defined + * by a higher layer replaces it rather than merging with it. + * + * @param array<string, MediaAttributes> $base + * @param array<string, MediaAttributes> $overlay + * @return array<string, MediaAttributes> + */ + protected function overlayMedia(array $base, array $overlay, bool $baseIsDefaults): array + { + if ($overlay === []) { + return $base; + } + + return $baseIsDefaults ? $overlay : array_replace($base, $overlay); + } + + /** + * Determine whether any media is defined. + */ + protected function hasMedia(): bool + { + return $this->images !== [] || $this->videos !== [] || $this->audios !== []; + } + + /** + * Determine whether no properties or media are defined. + */ + public function isEmpty(): bool + { + return $this->properties === [] && ! $this->hasMedia(); + } + + /** + * Get the Open Graph properties, falling back to the page title and description. + * + * @return array<string, string> + */ + public function render(?string $title = null, ?string $description = null): array + { + $properties = $this->properties; + + if ($title !== null && $title !== '' && ! isset($properties['title'])) { + $properties['title'] = $title; + } + + if ($description !== null && $description !== '' && ! isset($properties['description'])) { + $properties['description'] = $description; + } + + return $properties; + } + + /** + * Get the image definitions keyed by URL. + * + * @return array<string, MediaAttributes> + */ + public function images(): array + { + return $this->images; + } + + /** + * Get the resolved Open Graph metadata for the head array. + * + * @return array<string, mixed> + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->headArray($head->title(), $head->description()); + } + + /** + * Render Open Graph properties and grouped media tags. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + $properties = $this->render($head->title(), $head->description()); + $propertyTags = array_map( + fn (string $property, string $content): string => $tags->meta('property', 'og:' . $property, $content, 'og:' . $property), + array_keys($properties), + $properties, + ); + + return [ + ...$propertyTags, + ...$this->mediaTags($tags, 'image', $this->images), + ...$this->mediaTags($tags, 'video', $this->videos), + ...$this->mediaTags($tags, 'audio', $this->audios), + ]; + } + + /** + * Get nonempty Open Graph properties and media lists. + * + * @return array<string, mixed> + */ + protected function headArray(?string $title = null, ?string $description = null): array + { + return array_filter([ + ...$this->render($title, $description), + 'images' => array_values($this->images), + 'videos' => array_values($this->videos), + 'audios' => array_values($this->audios), + ], fn (string|array $value): bool => $value !== '' && $value !== []); + } + + /** + * Set each specified Open Graph property. + * + * @param array<string, null|string> $properties + */ + protected function set(array $properties): void + { + foreach ($properties as $property => $value) { + if (! is_null($value)) { + $this->properties[$property] = $value; + } + } + } + + /** + * Normalize a media definition, omitting unspecified attributes. + * + * @return MediaAttributes + */ + protected static function media( + string $url, + ?string $alt = null, + ?int $width = null, + ?int $height = null, + ?string $type = null, + ?string $secureUrl = null, + ): array { + return array_filter([ + 'url' => $url, + 'alt' => $alt, + 'width' => $width, + 'height' => $height, + 'type' => $type, + 'secureUrl' => $secureUrl, + ], fn (int|string|null $value): bool => ! is_null($value)); + } + + /** + * Resolve an Open Graph type value. + */ + protected static function ogType(mixed $value): OgType|string|null + { + return $value instanceof OgType || is_string($value) ? $value : null; + } + + /** + * Resolve an image MIME type. + */ + protected static function imageType(mixed $value): ?string + { + return $value instanceof ImageType ? $value->value : self::string($value); + } + + /** + * Render each media item with its associated attributes. + * + * @param array<string, MediaAttributes> $media + * @return array<int, string> + */ + protected function mediaTags(TagRenderer $tags, string $property, array $media): array + { + $attributes = [ + 'secureUrl' => 'secure_url', + 'type' => 'type', + 'width' => 'width', + 'height' => 'height', + 'alt' => 'alt', + ]; + + $rendered = []; + + foreach ($media as $item) { + $mediaKey = 'og:' . $property . ':' . $tags->stableKey('item', $item['url']); + + $rendered[] = $tags->meta('property', 'og:' . $property, $item['url'], $mediaKey); + + foreach ($attributes as $attribute => $name) { + if (isset($item[$attribute])) { + $rendered[] = $tags->meta('property', 'og:' . $property . ':' . $name, $item[$attribute], $mediaKey . ':' . $name); + } + } + } + + return $rendered; + } +} diff --git a/src/head/src/Tags/PaginationLinks.php b/src/head/src/Tags/PaginationLinks.php new file mode 100644 index 0000000000..b69b2e505b --- /dev/null +++ b/src/head/src/Tags/PaginationLinks.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Contracts\Pagination\Paginator; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +/** + * @phpstan-consistent-constructor + */ +class PaginationLinks extends GroupedTagBuilder +{ + /** + * Create the pagination links. + * + * @param array<string, string> $links + */ + public function __construct(protected array $links = []) + { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'pagination'; + } + + /** + * Get the key used in the head array. + */ + public static function headArrayKey(): string + { + return 'links.pagination'; + } + + /** + * Create previous and next links from a paginator. + * + * @param Paginator<int, mixed> $paginator + */ + public static function fromPaginator(Paginator $paginator): self + { + $links = new self; + + if ($previous = $paginator->previousPageUrl()) { + $links->link('prev', $previous); + } + + if ($next = $paginator->nextPageUrl()) { + $links->link('next', $next); + } + + return $links; + } + + /** + * Set a pagination link by relation. + */ + public function link(string $rel, string $href): static + { + $this->links[$rel] = $href; + + return $this; + } + + /** + * Overlay these pagination links onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static(array_replace($base->links, $this->links)); + } + + /** + * Determine whether no pagination links are defined. + */ + public function isEmpty(): bool + { + return $this->links === []; + } + + /** + * Get the pagination links for the head array. + * + * @return array<string, string> + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->links; + } + + /** + * Render the pagination link tags. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return array_map( + fn (string $rel, string $href): string => $tags->link($rel, $href, 'pagination:' . $rel), + array_keys($this->links), + $this->links, + ); + } +} diff --git a/src/head/src/Tags/PerformanceLinks.php b/src/head/src/Tags/PerformanceLinks.php new file mode 100644 index 0000000000..bc884f127a --- /dev/null +++ b/src/head/src/Tags/PerformanceLinks.php @@ -0,0 +1,438 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Enums\ImageType; +use Hypervel\Head\Enums\Media; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; +use InvalidArgumentException; + +/** + * @phpstan-consistent-constructor + * + * @phpstan-type LinkAttributes array{href: string, as?: string|null, crossorigin?: bool|string|null, type?: string|null, media?: string|null} + */ +class PerformanceLinks extends GroupedTagBuilder +{ + /** + * Create the resource hint definitions. + * + * @param array<string, LinkAttributes> $preloads + * @param array<string, LinkAttributes> $prefetches + * @param array<string, LinkAttributes> $preconnects + * @param array<string, array{href: string}> $dnsPrefetches + */ + public function __construct( + protected array $preloads = [], + protected array $prefetches = [], + protected array $preconnects = [], + protected array $dnsPrefetches = [], + ) { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'performance'; + } + + /** + * Get the key used in the head array. + */ + public static function headArrayKey(): string + { + return 'links.performance'; + } + + /** + * Get the empty resource hint groups. + */ + public static function headArrayDefault(): mixed + { + return [ + 'preload' => [], + 'prefetch' => [], + 'preconnect' => [], + 'dnsPrefetch' => [], + ]; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return ['preload', 'prefetch', 'preconnect', 'dnsPrefetch']; + } + + /** + * Build resource hints from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + return match ($key) { + 'preload' => is_string($value) || is_array($value) ? self::fromPreloadAttributes($value) : null, + 'prefetch' => is_string($value) || is_array($value) ? self::fromPrefetchAttributes($value) : null, + 'preconnect' => is_string($value) || is_array($value) ? self::fromPreconnectAttributes($value) : null, + 'dnsPrefetch' => is_string($value) || is_array($value) ? self::fromDnsPrefetchAttributes($value) : null, + default => null, + }; + } + + /** + * Build preload hints from route values. + * + * @param array<mixed, mixed>|string $preloads + */ + public static function fromPreloadAttributes(string|array $preloads): ?self + { + $links = new self; + + foreach (self::repeatableLinkAttributes($preloads, ['href', 'as', 'crossorigin', 'type', 'media']) as $href => $attributes) { + if (! $links->addPreloadRouteAttribute($href, $attributes)) { + return null; + } + } + + return $links; + } + + /** + * Build prefetch hints from route values. + * + * @param array<mixed, mixed>|string $prefetches + */ + public static function fromPrefetchAttributes(string|array $prefetches): ?self + { + $links = new self; + + foreach (self::repeatableLinkAttributes($prefetches, ['href', 'as']) as $href => $attributes) { + if (! $links->addPrefetchRouteAttribute($href, $attributes)) { + return null; + } + } + + return $links; + } + + /** + * Build preconnect hints from route values. + * + * @param array<mixed, mixed>|string $preconnects + */ + public static function fromPreconnectAttributes(string|array $preconnects): ?self + { + $links = new self; + + foreach (self::repeatableLinkAttributes($preconnects, ['href', 'crossorigin']) as $href => $attributes) { + if (! $links->addPreconnectRouteAttribute($href, $attributes)) { + return null; + } + } + + return $links; + } + + /** + * Build DNS prefetch hints from route values. + * + * @param array<mixed>|string $dnsPrefetches + */ + public static function fromDnsPrefetchAttributes(string|array $dnsPrefetches): ?self + { + $links = new self; + + foreach (self::items($dnsPrefetches) as $href) { + if (! is_string($href)) { + return null; + } + + $links->dnsPrefetch($href); + } + + return $links; + } + + /** + * Add or replace a preload hint by URL. + */ + public function preload(string $href, ?string $as = null, bool|string|null $crossorigin = null, ImageType|string|null $type = null, Media|string|null $media = null): static + { + $this->preloads[$href] = array_filter([ + 'href' => $href, + 'as' => $as, + 'crossorigin' => $crossorigin, + 'type' => $type instanceof ImageType ? $type->value : $type, + 'media' => $media instanceof Media ? $media->value : $media, + ], fn (bool|string|null $value): bool => ! is_null($value)); + + return $this; + } + + /** + * Preload an asset, inferring its resource type when omitted. + */ + public function preloadAsset(string $path, ?string $as = null, bool|string|null $crossorigin = null, ImageType|string|null $type = null, Media|string|null $media = null): static + { + $as ??= self::detectAs($path) ?? throw new InvalidArgumentException( + "Unable to detect a preload [as] attribute for asset [{$path}]. Pass one explicitly.", + ); + + if ($as === 'font') { + $crossorigin ??= true; + } + + return $this->preload(asset($path), as: $as, crossorigin: $crossorigin, type: $type, media: $media); + } + + /** + * Add or replace a prefetch hint by URL. + */ + public function prefetch(string $href, ?string $as = null): static + { + $this->prefetches[$href] = array_filter([ + 'href' => $href, + 'as' => $as, + ], fn (?string $value): bool => ! is_null($value)); + + return $this; + } + + /** + * Prefetch an asset, inferring its resource type when omitted. + */ + public function prefetchAsset(string $path, ?string $as = null): static + { + return $this->prefetch(asset($path), as: $as ?? self::detectAs($path)); + } + + /** + * Add or replace a preconnect hint by origin. + */ + public function preconnect(string $href, bool|string|null $crossorigin = null): static + { + $this->preconnects[$href] = array_filter([ + 'href' => $href, + 'crossorigin' => $crossorigin, + ], fn (bool|string|null $value): bool => ! is_null($value)); + + return $this; + } + + /** + * Add a DNS prefetch hint. + */ + public function dnsPrefetch(string $href): static + { + $this->dnsPrefetches[$href] = ['href' => $href]; + + return $this; + } + + /** + * Overlay these resource hints onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static( + array_replace($base->preloads, $this->preloads), + array_replace($base->prefetches, $this->prefetches), + array_replace($base->preconnects, $this->preconnects), + array_replace($base->dnsPrefetches, $this->dnsPrefetches), + ); + } + + /** + * Determine whether no resource hints are defined. + */ + public function isEmpty(): bool + { + return $this->preloads === [] + && $this->prefetches === [] + && $this->preconnects === [] + && $this->dnsPrefetches === []; + } + + /** + * Get the resource hints for the head array. + * + * @return array{preload: array<int, LinkAttributes>, prefetch: array<int, LinkAttributes>, preconnect: array<int, LinkAttributes>, dnsPrefetch: array<int, array{href: string}>} + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->headArray(); + } + + /** + * Render the resource hint tags. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return [ + ...array_map(fn (array $attributes): string => $tags->linkWithAttributes('preload', $attributes, $tags->stableKey('preload', $attributes['href'])), array_values($this->preloads)), + ...array_map(fn (array $attributes): string => $tags->linkWithAttributes('prefetch', $attributes, $tags->stableKey('prefetch', $attributes['href'])), array_values($this->prefetches)), + ...array_map(fn (array $attributes): string => $tags->linkWithAttributes('preconnect', $attributes, $tags->stableKey('preconnect', $attributes['href'])), array_values($this->preconnects)), + ...array_map(fn (array $attributes): string => $tags->linkWithAttributes('dns-prefetch', $attributes, $tags->stableKey('dns-prefetch', $attributes['href'])), array_values($this->dnsPrefetches)), + ]; + } + + /** + * Get ordered definitions for each resource hint group. + * + * @return array{preload: array<int, LinkAttributes>, prefetch: array<int, LinkAttributes>, preconnect: array<int, LinkAttributes>, dnsPrefetch: array<int, array{href: string}>} + */ + protected function headArray(): array + { + return [ + 'preload' => array_values($this->preloads), + 'prefetch' => array_values($this->prefetches), + 'preconnect' => array_values($this->preconnects), + 'dnsPrefetch' => array_values($this->dnsPrefetches), + ]; + } + + /** + * Infer a resource type from its file extension. + */ + protected static function detectAs(string $path): ?string + { + [$path] = explode('#', $path, 2); + [$path] = explode('?', $path, 2); + + return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { + 'woff2', 'woff', 'ttf', 'otf', 'eot' => 'font', + 'css' => 'style', + 'js', 'mjs' => 'script', + 'avif', 'webp', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico' => 'image', + 'mp4', 'webm', 'ogv' => 'video', + 'mp3', 'ogg', 'wav', 'flac', 'aac', 'm4a' => 'audio', + 'vtt' => 'track', + 'json' => 'fetch', + default => null, + }; + } + + /** + * Return a boolean or string attribute value. + */ + protected static function boolOrString(mixed $value): bool|string|null + { + return is_bool($value) || is_string($value) ? $value : null; + } + + /** + * Add a valid preload hint from route metadata. + */ + private function addPreloadRouteAttribute(mixed $href, mixed $attributes): bool + { + if (is_string($attributes)) { + $this->preload($attributes); + + return true; + } + + if (! is_array($attributes) || is_null($url = self::routeAttributeHref($href, $attributes))) { + return false; + } + + $this->preload( + $url, + as: self::string($attributes['as'] ?? null), + crossorigin: self::boolOrString($attributes['crossorigin'] ?? null), + type: self::stringOrBackedEnum($attributes['type'] ?? null), + media: self::stringOrBackedEnum($attributes['media'] ?? null), + ); + + return true; + } + + /** + * Add a valid prefetch hint from route metadata. + */ + private function addPrefetchRouteAttribute(mixed $href, mixed $attributes): bool + { + if (is_string($attributes)) { + $this->prefetch($attributes); + + return true; + } + + if (! is_array($attributes) || is_null($url = self::routeAttributeHref($href, $attributes))) { + return false; + } + + $this->prefetch($url, as: self::string($attributes['as'] ?? null)); + + return true; + } + + /** + * Add a valid preconnect hint from route metadata. + */ + private function addPreconnectRouteAttribute(mixed $href, mixed $attributes): bool + { + if (is_string($attributes)) { + $this->preconnect($attributes); + + return true; + } + + if (! is_array($attributes) || is_null($url = self::routeAttributeHref($href, $attributes))) { + return false; + } + + $this->preconnect($url, crossorigin: self::boolOrString($attributes['crossorigin'] ?? null)); + + return true; + } + + /** + * Normalize a route attribute value into a list of link definitions, + * treating an attribute map containing any single-link key as one link. + * + * @param array<mixed, mixed>|string $value + * @param array<int, string> $singleAttributeKeys + * @return array<mixed, mixed> + */ + private static function repeatableLinkAttributes(string|array $value, array $singleAttributeKeys): array + { + if (is_string($value)) { + return [$value]; + } + + if (array_is_list($value)) { + return $value; + } + + foreach ($singleAttributeKeys as $key) { + if (array_key_exists($key, $value)) { + return [$value]; + } + } + + return $value; + } + + /** + * Resolve a hint URL from an explicit attribute or its map key. + * + * @param array<mixed, mixed> $attributes + */ + private static function routeAttributeHref(mixed $href, array $attributes): ?string + { + return self::string($attributes['href'] ?? null) ?? self::string($href); + } +} diff --git a/src/head/src/Tags/Robots.php b/src/head/src/Tags/Robots.php new file mode 100644 index 0000000000..8b5b6a6e5c --- /dev/null +++ b/src/head/src/Tags/Robots.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Enums\RobotsRule; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +class Robots extends StringTagBuilder +{ + /** + * Get the builder key. + */ + public static function key(): string + { + return 'robots'; + } + + /** + * Create a robots tag from directives. + * + * @param array<mixed, mixed>|RobotsRule|string $directives + */ + public static function make(string|RobotsRule|array $directives): static + { + return new static(self::renderDirectives($directives)); + } + + /** + * Build robots directives from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key !== static::key()) { + return null; + } + + if (is_array($value)) { + if ($value === []) { + return new static; + } + + $directives = static::renderDirectives($value); + + return $directives === '' ? null : new static($directives); + } + + return is_string($value) || $value instanceof RobotsRule ? static::make($value) : null; + } + + /** + * Render the robots meta tag. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return ($robots = $this->render()) ? [$tags->meta('name', 'robots', $robots, 'robots')] : []; + } + + /** + * Normalize robots directives into a comma-separated value. + * + * @param array<mixed, mixed>|RobotsRule|string $directives + */ + protected static function renderDirectives(string|RobotsRule|array $directives): string + { + if (is_string($directives)) { + return $directives; + } + + if ($directives instanceof RobotsRule) { + return $directives->value; + } + + return implode(', ', array_values(array_filter(array_map( + fn (mixed $directive): ?string => match (true) { + $directive instanceof RobotsRule => $directive->value, + is_string($directive) => $directive, + default => null, + }, + $directives, + )))); + } +} diff --git a/src/head/src/Tags/Schemas.php b/src/head/src/Tags/Schemas.php new file mode 100644 index 0000000000..a604b0d2b4 --- /dev/null +++ b/src/head/src/Tags/Schemas.php @@ -0,0 +1,159 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; +use Hypervel\Head\Schema\SchemaObject; + +/** + * @phpstan-consistent-constructor + * + * @phpstan-type SchemaData array<string, mixed> + */ +class Schemas extends GroupedTagBuilder +{ + /** + * Create the schema collection. + * + * @param array<string, SchemaData|SchemaObject> $schemas + */ + public function __construct(protected array $schemas = []) + { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'schemas'; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return ['schema']; + } + + /** + * Build schemas from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key !== 'schema' || (! $value instanceof SchemaObject && ! is_array($value))) { + return null; + } + + $builder = new self; + + if ($value instanceof SchemaObject || ! array_is_list($value)) { + return $builder->schema($value instanceof SchemaObject ? $value : self::named($value)); + } + + foreach ($value as $schema) { + if ($schema instanceof SchemaObject || is_array($schema)) { + $builder->schema($schema instanceof SchemaObject ? $schema : self::named($schema)); + } + } + + return $builder; + } + + /** + * Add a schema, deduplicating objects by identity and arrays by content. + * + * @param SchemaData|SchemaObject $schema + */ + public function schema(SchemaObject|array $schema): static + { + $this->schemas[$schema instanceof SchemaObject ? spl_object_hash($schema) : hash('xxh128', json_encode($schema, JSON_THROW_ON_ERROR))] = $schema; + + return $this; + } + + /** + * Overlay these schemas onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static(array_replace($base->schemas, $this->schemas)); + } + + /** + * Determine whether no schemas are defined. + */ + public function isEmpty(): bool + { + return $this->schemas === []; + } + + /** + * Validate and resolve all schemas for the head array. + * + * @return array<int, SchemaData> + */ + public function toHeadArray(ResolvedHead $head): array + { + return array_map(function (SchemaObject|array $schema) use ($head): array { + $schemaData = $schema instanceof SchemaObject ? $schema->toJsonLd() : $schema; + + $head->validateSchema($schemaData); + + return $schemaData; + }, $this->headArray()); + } + + /** + * Render each schema as JSON-LD. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return array_map( + fn (array $schema): string => $tags->jsonLd($schema), + $this->toHeadArray($head), + ); + } + + /** + * Get the ordered schema definitions. + * + * @return array<int, SchemaData|SchemaObject> + */ + protected function headArray(): array + { + return array_values($this->schemas); + } + + /** + * Copy mutable schema objects for independent coroutine state. + */ + public function __clone(): void + { + $schemas = []; + + foreach ($this->schemas as $key => $schema) { + if ($schema instanceof SchemaObject) { + $schema = clone $schema; + // Original identities can be reused after their objects are released. + $key = spl_object_hash($schema); + } + + $schemas[$key] = $schema; + } + + $this->schemas = $schemas; + } +} diff --git a/src/head/src/Tags/StringTagBuilder.php b/src/head/src/Tags/StringTagBuilder.php new file mode 100644 index 0000000000..d3a642c3c9 --- /dev/null +++ b/src/head/src/Tags/StringTagBuilder.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Rendering\ResolvedHead; + +/** + * A tag builder holding a single string value. + * + * @phpstan-consistent-constructor + */ +abstract class StringTagBuilder extends TagBuilder +{ + /** + * Create a tag with an optional string value. + */ + public function __construct(protected ?string $value = null) + { + } + + /** + * Create a tag from a string value. + */ + public static function make(string $value): static + { + return new static($value); + } + + /** + * Build a string tag from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + return $key === static::key() && is_string($value) + ? static::make($value) + : null; + } + + /** + * Overlay this value onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static($this->value ?? $base->value); + } + + /** + * Determine whether no string value is defined. + */ + public function isEmpty(): bool + { + return is_null($this->value); + } + + /** + * Get the tag value. + */ + public function render(): ?string + { + return $this->value; + } + + /** + * Get the value for the head array. + */ + public function toHeadArray(ResolvedHead $head): ?string + { + return $this->render(); + } +} diff --git a/src/head/src/Tags/TagBuilder.php b/src/head/src/Tags/TagBuilder.php new file mode 100644 index 0000000000..8fb17e6a29 --- /dev/null +++ b/src/head/src/Tags/TagBuilder.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use BackedEnum; +use Hypervel\Contracts\Container\Transient; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +abstract class TagBuilder implements Transient +{ + /** + * Get the unique name identifying this builder within the head. + */ + abstract public static function key(): string; + + /** + * Merge this builder over the given base builder, preferring this builder's values. + */ + abstract public function overlayOn(?self $base): static; + + /** + * Determine whether the builder has no data. + */ + abstract public function isEmpty(): bool; + + /** + * Get the dot-notated key this builder occupies in the Head::toArray() result. + */ + public static function headArrayKey(): string + { + return static::key(); + } + + /** + * Get the value used in Head::toArray() when this builder has no data. + */ + public static function headArrayDefault(): mixed + { + return null; + } + + /** + * Get the route attribute keys this builder accepts values for. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return [static::key()]; + } + + /** + * Create a builder from a route attribute value, or null when the value is invalid. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + return null; + } + + /** + * Determine if the builder should render even when no data was set. + */ + public static function rendersWhenEmpty(ResolvedHead $head): bool + { + return false; + } + + /** + * Mark the builder's data as belonging to the defaults layer. + */ + public function asDefaults(): static + { + return $this; + } + + /** + * Convert this builder into its Head::toArray() value. + */ + public function toHeadArray(ResolvedHead $head): mixed + { + return null; + } + + /** + * Convert this builder into the HTML tags rendered by @head. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + return []; + } + + /** + * Return the value when it is a string. + */ + protected static function string(mixed $value): ?string + { + return is_string($value) ? $value : null; + } + + /** + * Resolve a string or string-backed enum value. + */ + protected static function stringOrBackedEnum(mixed $value): ?string + { + if (is_string($value)) { + return $value; + } + + if ($value instanceof BackedEnum && is_string($value->value)) { + return $value->value; + } + + return null; + } + + /** + * Return the value when it is a boolean. + */ + protected static function bool(mixed $value): ?bool + { + return is_bool($value) ? $value : null; + } + + /** + * Return the value when it is an integer. + */ + protected static function int(mixed $value): ?int + { + return is_int($value) ? $value : null; + } + + /** + * Normalize a route attribute value into a list of items. + * + * @return array<int, mixed> + */ + protected static function items(mixed $value): array + { + return is_array($value) && array_is_list($value) ? $value : [$value]; + } + + /** + * Filter an array down to its string-keyed values. + * + * @param array<mixed, mixed> $values + * @return array<string, mixed> + */ + protected static function named(array $values): array + { + $named = []; + + foreach ($values as $key => $value) { + if (is_string($key)) { + $named[$key] = $value; + } + } + + return $named; + } +} diff --git a/src/head/src/Tags/Title.php b/src/head/src/Tags/Title.php new file mode 100644 index 0000000000..473db4ea33 --- /dev/null +++ b/src/head/src/Tags/Title.php @@ -0,0 +1,142 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; + +/** + * @phpstan-consistent-constructor + */ +class Title extends TagBuilder +{ + /** + * Create a title definition with optional decoration. + */ + public function __construct( + protected ?string $value = null, + protected ?bool $exact = null, + protected ?string $prefix = null, + protected ?string $suffix = null, + ) { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'title'; + } + + /** + * Create a title with inherited decoration unless exact rendering is requested. + */ + public static function make(string $value, ?string $prefix = null, ?string $suffix = null, ?bool $exact = null): self + { + return new self($value, $exact ?? false, $prefix, $suffix); + } + + /** + * Build a title from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + if ($key !== 'title') { + return null; + } + + $title = $value; + + if (is_string($title)) { + return self::make($title); + } + + if (! is_array($title) || ! is_string($title['value'] ?? null)) { + return null; + } + + return self::make( + $title['value'], + prefix: self::string($title['prefix'] ?? null), + suffix: self::string($title['suffix'] ?? null), + exact: self::bool($title['exact'] ?? null), + ); + } + + /** + * Overlay this title while inheriting unspecified decoration. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static( + $this->value ?? $base->value, + $this->exact ?? $base->exact, + $this->prefix ?? $base->prefix, + $this->suffix ?? $base->suffix, + ); + } + + /** + * Default titles render exactly; the prefix and suffix only decorate page titles. + */ + public function asDefaults(): static + { + return is_null($this->value) + ? $this + : new static($this->value, true, $this->prefix, $this->suffix); + } + + /** + * Determine whether no title value or options are defined. + */ + public function isEmpty(): bool + { + return is_null($this->value) + && is_null($this->exact) + && is_null($this->prefix) + && is_null($this->suffix); + } + + /** + * Render the title with its effective decoration. + */ + public function render(): ?string + { + if (is_null($this->value)) { + return null; + } + + if ($this->exact === true) { + return $this->value; + } + + return ($this->prefix ?? '') . $this->value . ($this->suffix ?? ''); + } + + /** + * Get the rendered title for the head array. + */ + public function toHeadArray(ResolvedHead $head): ?string + { + return $this->render(); + } + + /** + * Render the title tag. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + $title = $this->render(); + + return $title !== null && $title !== '' ? [$tags->title($title, 'title')] : []; + } +} diff --git a/src/head/src/Tags/Twitter.php b/src/head/src/Tags/Twitter.php new file mode 100644 index 0000000000..2c9ec650a3 --- /dev/null +++ b/src/head/src/Tags/Twitter.php @@ -0,0 +1,263 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Head\Enums\TwitterCard; +use Hypervel\Head\Rendering\ResolvedHead; +use Hypervel\Head\Rendering\TagRenderer; +use Hypervel\Support\Arr; + +/** + * @phpstan-consistent-constructor + * + * @phpstan-type ImageAttributes array<string, string> + */ +class Twitter extends GroupedTagBuilder +{ + /** + * Create the Twitter card properties and image. + * + * @param array<string, string> $properties + * @param null|ImageAttributes $image + */ + public function __construct( + protected array $properties = [], + protected ?array $image = null, + ) { + } + + /** + * Get the builder key. + */ + public static function key(): string + { + return 'twitter'; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return ['twitter', 'twitterImage']; + } + + /** + * Create Twitter card metadata from named values. + */ + public static function make( + TwitterCard|string|null $card = null, + ?string $site = null, + ?string $creator = null, + ?string $title = null, + ?string $description = null, + ?string $image = null, + ): self { + $twitter = new self; + + $twitter->set([ + 'card' => $card instanceof TwitterCard ? $card->value : $card, + 'site' => $site, + 'creator' => $creator, + 'title' => $title, + 'description' => $description, + ]); + + if (! is_null($image)) { + $twitter->image($image); + } + + return $twitter; + } + + /** + * Build Twitter card metadata from a route attribute. + */ + public static function fromRouteAttribute(string $key, mixed $value): ?self + { + return match ($key) { + 'twitter' => is_array($value) ? self::fromAttributes($value) : null, + 'twitterImage' => self::fromImageAttributes($value), + default => null, + }; + } + + /** + * Create Twitter card metadata from an attribute map. + * + * @param array<mixed, mixed> $values + */ + public static function fromAttributes(array $values): self + { + return self::make( + card: self::twitterCard($values['card'] ?? null), + site: self::string($values['site'] ?? null), + creator: self::string($values['creator'] ?? null), + title: self::string($values['title'] ?? null), + description: self::string($values['description'] ?? null), + image: self::string($values['image'] ?? null), + ); + } + + /** + * Build a Twitter image from route values. + */ + public static function fromImageAttributes(mixed $image): self + { + $twitter = new self; + + if (is_string($image)) { + return $twitter->image($image); + } + + if (is_array($image) && is_string($image['url'] ?? null)) { + return $twitter->image($image['url'], alt: self::string($image['alt'] ?? null)); + } + + return $twitter; + } + + /** + * Set the Twitter card image. + */ + public function image(string $url, ?string $alt = null): static + { + $this->image = Arr::whereNotNull([ + 'url' => $url, + 'alt' => $alt, + ]); + + return $this; + } + + /** + * Overlay these card properties and image onto the lower layer. + */ + public function overlayOn(?TagBuilder $base): static + { + if (! $base instanceof self) { + return $this; + } + + return new static( + array_replace($base->properties, $this->properties), + $this->image ?? $base->image, + ); + } + + /** + * Determine whether no card properties or image are defined. + */ + public function isEmpty(): bool + { + return $this->properties === [] && is_null($this->image); + } + + /** + * Get the Twitter card properties, falling back to the page title and description. + * + * @return array<string, string> + */ + public function render(?string $title = null, ?string $description = null): array + { + $properties = $this->properties; + + if ($title !== null && $title !== '' && ! isset($properties['title'])) { + $properties['title'] = $title; + } + + if ($description !== null && $description !== '' && ! isset($properties['description'])) { + $properties['description'] = $description; + } + + return $properties; + } + + /** + * Get the resolved Twitter card metadata for the head array. + * + * @return array<string, mixed> + */ + public function toHeadArray(ResolvedHead $head): array + { + return $this->headArray($head->title(), $head->description(), $head->openGraphImage()); + } + + /** + * Render the Twitter card meta tags. + * + * @return array<int, string> + */ + public function toTags(ResolvedHead $head, TagRenderer $tags): array + { + $properties = $this->render($head->title(), $head->description()); + $rendered = array_map( + fn (string $name, string $content): string => $tags->meta('name', 'twitter:' . $name, $content, 'twitter:' . $name), + array_keys($properties), + $properties, + ); + + if (is_null($image = $this->headArrayImage($head->openGraphImage()))) { + return $rendered; + } + + $rendered[] = $tags->meta('name', 'twitter:image', $image['url'], 'twitter:image'); + + if (isset($image['alt'])) { + $rendered[] = $tags->meta('name', 'twitter:image:alt', $image['alt'], 'twitter:image:alt'); + } + + return $rendered; + } + + /** + * Get the card image or its Open Graph fallback. + * + * @param null|ImageAttributes $fallback + * @return null|ImageAttributes + */ + protected function headArrayImage(?array $fallback = null): ?array + { + return $this->image ?? $fallback; + } + + /** + * Get nonempty Twitter card properties and the resolved image. + * + * @param null|ImageAttributes $fallbackImage + * @return array<string, mixed> + */ + protected function headArray(?string $title = null, ?string $description = null, ?array $fallbackImage = null): array + { + return array_filter([ + ...$this->render($title, $description), + 'image' => $this->headArrayImage($fallbackImage), + ], fn (string|array|null $value): bool => $value !== null && $value !== '' && $value !== []); + } + + /** + * Set each specified Twitter card property. + * + * @param array<string, null|string> $properties + */ + protected function set(array $properties): void + { + foreach ($properties as $property => $value) { + if (! is_null($value)) { + $this->properties[$property] = $value; + } + } + } + + /** + * Resolve a Twitter card type value. + */ + protected static function twitterCard(mixed $value): TwitterCard|string|null + { + return $value instanceof TwitterCard || is_string($value) ? $value : null; + } +} diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index c04291cc71..0b4f3b0b09 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -334,6 +334,7 @@ protected function flushFrameworkState(): void $this->flushAiState(); $this->flushDataState(); $this->flushFortifyState(); + $this->flushHeadState(); $this->flushHorizonState(); $this->flushImageState(); $this->flushInertiaState(); @@ -383,6 +384,16 @@ protected function flushFortifyState(): void $this->callIfExists(\Hypervel\Fortify\Fortify::class, 'flushState'); } + /** + * Flush Head state. + */ + protected function flushHeadState(): void + { + $this->callIfExists(\Hypervel\Head\Tags\Canonical::class, 'flushState'); + $this->callIfExists(\Hypervel\Head\Schema\SchemaObject::class, 'flushState'); + $this->callIfExists(\Hypervel\Head\HeadManager::class, 'flushMacros'); + } + /** * Flush Horizon state. */ diff --git a/types/Head/Facades.php b/types/Head/Facades.php new file mode 100644 index 0000000000..ccaf97fbfc --- /dev/null +++ b/types/Head/Facades.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Types\Head; + +use Hypervel\Head\Facades\Head; +use Hypervel\Head\Facades\Schema; +use Hypervel\Head\HeadBuilder; +use Hypervel\Head\HeadManager; +use Hypervel\Head\Schema\Article; +use Hypervel\Head\Schema\SchemaFactory; +use Hypervel\Head\Schema\SchemaObject; + +use function PHPStan\Testing\assertType; + +assertType(Article::class, Schema::article()); + +Head::schema(function (SchemaFactory $schema): Article { + assertType(Article::class, $schema->article()); + + return $schema->article()->headline('Article'); +}); + +assertType(CustomHeadSchema::class, Schema::make(CustomHeadSchema::class)); + +Head::defaults(function ($head): void { + assertType(HeadBuilder::class, $head); +}); + +assertType(HeadManager::class, Head::when(true, fn ($head): HeadManager => $head->title('Page'))->description('Description')); + +// @phpstan-ignore argument.type +Head::resolveErrorsUsing(fn (string $head): string => $head); + +class CustomHeadSchema extends SchemaObject +{ +} diff --git a/types/Head/Route.php b/types/Head/Route.php new file mode 100644 index 0000000000..98c80d4ea9 --- /dev/null +++ b/types/Head/Route.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Types\Head; + +use Hypervel\Head\Enums\RobotsRule; +use Hypervel\Routing\PendingResourceRegistration; +use Hypervel\Routing\PendingSingletonResourceRegistration; +use Hypervel\Routing\Route; +use Hypervel\Routing\Router; +use Hypervel\Routing\RouteRegistrar; +use Hypervel\Support\Facades\Route as RouteFacade; + +use function PHPStan\Testing\assertType; + +function routingMacros( + Router $router, + RouteRegistrar $registrar, + Route $route, + PendingResourceRegistration $resource, + PendingSingletonResourceRegistration $singleton, +): void { + assertType(RouteRegistrar::class, $router->withHead(title: 'Page')); + assertType(RouteRegistrar::class, $registrar->withHead(description: 'Description')); + assertType(Route::class, $route->withHead(canonical: true)); + assertType(PendingResourceRegistration::class, $resource->withHead(robots: RobotsRule::NoIndex)); + assertType(PendingSingletonResourceRegistration::class, $singleton->withHead(robots: [RobotsRule::NoIndex])); + assertType(RouteRegistrar::class, RouteFacade::withHead(title: 'Group')->prefix('admin')); + assertType(Route::class, RouteFacade::prefix('admin')->withHead(title: 'Group')->get('/')); + assertType(Route::class, RouteFacade::get('/')->withHead(title: 'Page')->middleware('auth')); + assertType(RouteRegistrar::class, $router->middleware('auth')); + assertType(PendingResourceRegistration::class, RouteFacade::resource('posts', 'PostController')->withHead(title: 'Posts')); + assertType(PendingSingletonResourceRegistration::class, RouteFacade::singleton('profile', 'ProfileController')->withHead(title: 'Profile')); + + $route->withHead( + title: ['value' => 'Page', 'suffix' => ' - Site'], + description: 'Description', + canonical: ['value' => '/page', 'forceHttps' => true], + robots: ['index', RobotsRule::Follow], + themeColor: '#000000', + applicationName: 'Site', + colorScheme: 'light dark', + referrer: 'same-origin', + viewport: 'width=device-width', + appleWebAppTitle: 'Site', + webAppCapable: true, + appleWebAppStatusBarStyle: 'default', + og: ['type' => 'website'], + ogImage: '/image.png', + ogVideo: ['url' => '/video.mp4'], + ogAudio: '/audio.mp3', + twitter: ['site' => '@site'], + twitterImage: '/image.png', + preload: ['href' => '/style.css', 'as' => 'style'], + prefetch: '/next', + preconnect: 'https://assets.test', + dnsPrefetch: ['https://assets.test'], + alternates: ['en' => '/en'], + feed: ['href' => '/feed', 'title' => 'Feed'], + icon: '/icon.png', + favicon: '/favicon.ico', + appleTouchIcon: '/apple.png', + appleTouchStartupImage: '/startup.png', + maskIcon: ['href' => '/mask.svg', 'color' => '#000000'], + manifest: '/site.webmanifest', + schema: ['@type' => 'WebPage'], + meta: ['author' => 'Author'], + link: [['rel' => 'help', 'href' => '/help']], + extensions: ['readingTime' => 5], + ); + + $route->withHead(heading: 'Page'); // @phpstan-ignore argument.unknown + $route->withHead(title: 123); // @phpstan-ignore argument.type +} + +class CustomRoute extends Route +{ +} + +class CustomRouter extends Router +{ +} + +function routingSubclassMacros(CustomRoute $route, CustomRouter $router): void +{ + assertType(CustomRoute::class, $route->withHead(title: 'Page')); + assertType(RouteRegistrar::class, $router->withHead(title: 'Group')); +} From be134b44fc7f942ac8ab76d642df7474e270857f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:58:01 +0000 Subject: [PATCH 04/15] Port Head tests and cover request isolation and rendering fixes Convert every applicable upstream Pest case to PHPUnit while preserving the upstream layout and fixture coverage. Record the intentional Livewire exclusion and use the shared Hypervel test lifecycle for static cleanup. Add focused coverage for concurrent requests, copied contexts, mutable builder lifetimes, lazy runtime resolvers, canonical origins, independent media fallback, manifest ownership, schema factories, and literal zero values. Exercise optional provider discovery, early handler and kernel resolution, actual exception status, SSR capability forwarding, custom props, partial reloads, and both Blade SSR ordering paths. Validation: all 180 Head cases passed with 640 assertions. Test files were checked during porting, and the complete framework run exposed no Head failures. No external services or new test framework are required for these tests. --- tests/Head/ArchTest.php | 82 ++++ tests/Head/CoroutineIsolationTest.php | 156 ++++++ tests/Head/Feature/ErrorPagesTest.php | 166 +++++++ tests/Head/Feature/HeadBuilderTest.php | 100 ++++ tests/Head/Feature/HeadDataTest.php | 72 +++ tests/Head/Feature/HeadManagerTest.php | 161 +++++++ .../Head/Feature/HeadServiceProviderTest.php | 87 ++++ tests/Head/Feature/InertiaCustomPropTest.php | 39 ++ tests/Head/Feature/InertiaTest.php | 444 ++++++++++++++++++ .../Feature/Rendering/HeadRendererTest.php | 148 ++++++ .../Feature/Rendering/TagRendererTest.php | 95 ++++ tests/Head/Feature/Routing/RouteHeadTest.php | 221 +++++++++ tests/Head/Feature/Routing/WithHeadTest.php | 113 +++++ tests/Head/Feature/Schema/SchemaTest.php | 215 +++++++++ tests/Head/Feature/Tags/CanonicalTest.php | 129 +++++ tests/Head/Feature/Tags/GenericLinksTest.php | 96 ++++ tests/Head/Feature/Tags/MetaTagsTest.php | 131 ++++++ tests/Head/Feature/Tags/OpenGraphTest.php | 131 ++++++ .../Feature/Tags/PerformanceLinksTest.php | 69 +++ tests/Head/Feature/Tags/PwaTest.php | 31 ++ tests/Head/Feature/Tags/RobotsTest.php | 70 +++ tests/Head/Feature/Tags/ThemeColorTest.php | 64 +++ tests/Head/Feature/Tags/TitleTest.php | 41 ++ tests/Head/Feature/Tags/TwitterTest.php | 91 ++++ .../Fixtures/ConflictingRouteAttribute.php | 47 ++ tests/Head/Fixtures/JobPosting.php | 29 ++ tests/Head/Fixtures/ReadingTime.php | 86 ++++ .../Head/Fixtures/ResolvesHttpKernelEarly.php | 25 + .../Fixtures/views/app-directives.blade.php | 10 + .../views/app-inertia-head-first.blade.php | 10 + .../Head/Fixtures/views/app-nested.blade.php | 9 + tests/Head/Fixtures/views/app.blade.php | 10 + .../Head/Fixtures/views/errors/404.blade.php | 9 + .../Head/Fixtures/views/nested-head.blade.php | 1 + tests/Head/TestCase.php | 36 ++ 35 files changed, 3224 insertions(+) create mode 100644 tests/Head/ArchTest.php create mode 100644 tests/Head/CoroutineIsolationTest.php create mode 100644 tests/Head/Feature/ErrorPagesTest.php create mode 100644 tests/Head/Feature/HeadBuilderTest.php create mode 100644 tests/Head/Feature/HeadDataTest.php create mode 100644 tests/Head/Feature/HeadManagerTest.php create mode 100644 tests/Head/Feature/HeadServiceProviderTest.php create mode 100644 tests/Head/Feature/InertiaCustomPropTest.php create mode 100644 tests/Head/Feature/InertiaTest.php create mode 100644 tests/Head/Feature/Rendering/HeadRendererTest.php create mode 100644 tests/Head/Feature/Rendering/TagRendererTest.php create mode 100644 tests/Head/Feature/Routing/RouteHeadTest.php create mode 100644 tests/Head/Feature/Routing/WithHeadTest.php create mode 100644 tests/Head/Feature/Schema/SchemaTest.php create mode 100644 tests/Head/Feature/Tags/CanonicalTest.php create mode 100644 tests/Head/Feature/Tags/GenericLinksTest.php create mode 100644 tests/Head/Feature/Tags/MetaTagsTest.php create mode 100644 tests/Head/Feature/Tags/OpenGraphTest.php create mode 100644 tests/Head/Feature/Tags/PerformanceLinksTest.php create mode 100644 tests/Head/Feature/Tags/PwaTest.php create mode 100644 tests/Head/Feature/Tags/RobotsTest.php create mode 100644 tests/Head/Feature/Tags/ThemeColorTest.php create mode 100644 tests/Head/Feature/Tags/TitleTest.php create mode 100644 tests/Head/Feature/Tags/TwitterTest.php create mode 100644 tests/Head/Fixtures/ConflictingRouteAttribute.php create mode 100644 tests/Head/Fixtures/JobPosting.php create mode 100644 tests/Head/Fixtures/ReadingTime.php create mode 100644 tests/Head/Fixtures/ResolvesHttpKernelEarly.php create mode 100644 tests/Head/Fixtures/views/app-directives.blade.php create mode 100644 tests/Head/Fixtures/views/app-inertia-head-first.blade.php create mode 100644 tests/Head/Fixtures/views/app-nested.blade.php create mode 100644 tests/Head/Fixtures/views/app.blade.php create mode 100644 tests/Head/Fixtures/views/errors/404.blade.php create mode 100644 tests/Head/Fixtures/views/nested-head.blade.php create mode 100644 tests/Head/TestCase.php diff --git a/tests/Head/ArchTest.php b/tests/Head/ArchTest.php new file mode 100644 index 0000000000..b5095852dd --- /dev/null +++ b/tests/Head/ArchTest.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Tests\Head; + +use Hypervel\Tests\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +class ArchTest extends TestCase +{ + public function testStrictTypes(): void + { + foreach ($this->sourceFiles() as $path => $source) { + $this->assertMatchesRegularExpression('/declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/', $source, $path); + } + } + + public function testNoDebugging(): void + { + $this->assertNoFunctionCalls(['dd', 'dump', 'var_dump', 'die', 'ray']); + } + + public function testNoDirectEnvCalls(): void + { + $this->assertNoFunctionCalls(['env']); + } + + /** + * Read the package's PHP source files. + * + * @return iterable<string, string> + */ + private function sourceFiles(): iterable + { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../../src/head/src')); + + foreach ($files as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + yield $file->getPathname() => file_get_contents($file->getPathname()); + } + } + } + + /** + * Reject calls to prohibited functions without matching comments or strings. + * + * @param array<int, string> $functions + */ + private function assertNoFunctionCalls(array $functions): void + { + $violations = []; + + foreach ($this->sourceFiles() as $path => $source) { + $tokens = array_values(array_filter(token_get_all($source), static fn (array|string $token): bool => ! is_array($token) + || ! in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true))); + + foreach ($tokens as $index => $token) { + if (! is_array($token)) { + continue; + } + + $previous = $tokens[$index - 1] ?? null; + + if (is_array($previous) && in_array($previous[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW], true)) { + continue; + } + + if ($token[0] === T_EXIT && in_array('die', $functions, true)) { + $violations[] = $path . ':' . $token[2]; + } elseif (in_array($token[0], [T_STRING, T_NAME_FULLY_QUALIFIED], true) + && in_array(strtolower(ltrim($token[1], '\\')), $functions, true) + && ($tokens[$index + 1] ?? null) === '(') { + $violations[] = $path . ':' . $token[2]; + } + } + } + + $this->assertSame([], $violations); + } +} diff --git a/tests/Head/CoroutineIsolationTest.php b/tests/Head/CoroutineIsolationTest.php new file mode 100644 index 0000000000..4b3ff11412 --- /dev/null +++ b/tests/Head/CoroutineIsolationTest.php @@ -0,0 +1,156 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Tests\Head; + +use Closure; +use Hypervel\Context\RequestContext; +use Hypervel\Contracts\Foundation\Application; +use Hypervel\Head\CurrentHead; +use Hypervel\Head\Facades\Head; +use Hypervel\Head\HeadBuilder; +use Hypervel\Head\HeadData; +use Hypervel\Head\HeadManager; +use Hypervel\Head\Inertia\ShareHead; +use Hypervel\Head\Schema\Article; +use Hypervel\Head\Tags\MetaTags; +use Hypervel\Head\Tags\Schemas; +use Hypervel\Http\Request; +use Hypervel\Inertia\Inertia; +use Hypervel\Inertia\Support\Header; +use ReflectionProperty; +use Symfony\Component\HttpFoundation\Response; + +use function Hypervel\Coroutine\parallel; + +class CoroutineIsolationTest extends TestCase +{ + /** + * Disable the external SSR server while exercising the rendering integration. + */ + protected function defineEnvironment(Application $app): void + { + parent::defineEnvironment($app); + + $app->make('config')->set('inertia.ssr.enabled', false); + } + + public function testContainerResolvesIndependentMutableBuildersAndSchemas(): void + { + $first = $this->app->make(HeadBuilder::class); + $second = $this->app->make(HeadBuilder::class); + $first->title('First'); + + $this->assertNotSame($first, $second); + $data = new ReflectionProperty(HeadBuilder::class, 'data'); + $this->assertTrue($data->getValue($second)->isEmpty()); + $this->assertNotSame($this->app->make(HeadData::class), $this->app->make(HeadData::class)); + + $firstSchema = $this->app->make(Article::class)->headline('First'); + $secondSchema = $this->app->make(Article::class); + $this->assertNotSame($firstSchema, $secondSchema); + $this->assertSame(['@type' => 'Article'], $secondSchema->toArray()); + } + + public function testConcurrentTeamsHaveIndependentBladeAndInertiaMetadata(): void + { + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Shared default')); + Head::resolveDefaultsUsing(function (HeadBuilder $head): void { + $team = RequestContext::get()->attributes->get('team'); + $head->title('Team ' . $team, suffix: ' - ' . $team) + ->manifest('/' . $team . '.webmanifest') + ->canonical(); + }); + Head::resolveErrorsUsing(function (HeadBuilder $head, int $status): void { + $head->description(RequestContext::get()->attributes->get('team') . ':' . $status); + }); + Head::resolveCanonicalOriginUsing(fn (): string => 'https://' . RequestContext::get()->attributes->get('team') . '.test'); + + $middleware = new ShareHead($this->app->make(HeadManager::class)); + $render = fn (string $team, int $status): Closure => function () use ($team, $status, $middleware): array { + $request = Request::create('http://alias.test/' . $team); + $request->attributes->set('team', $team); + $request->headers->set(Header::INERTIA, 'true'); + RequestContext::set($request); + Head::title('Page ' . $team)->status($status); + usleep(5000); + + $json = $middleware->handle($request, fn (Request $request): Response => Inertia::render('Team')->toResponse($request)); + $elements = json_decode($json->getContent(), true, flags: JSON_THROW_ON_ERROR)['props']['head']; + $blade = Head::render()->toHtml(); + + $request->headers->remove(Header::INERTIA); + $document = Inertia::render('Team')->toResponse($request)->getContent(); + + return [$blade, $elements, $document]; + }; + + [$first, $second] = parallel([$render('first', 404), $render('second', 503)]); + + foreach ([['first', 404, $first], ['second', 503, $second]] as [$team, $status, $result]) { + [$blade, $elements, $document] = $result; + $this->assertStringContainsString('<title>Page ' . $team . ' - ' . $team . '', $blade); + $this->assertStringContainsString('', $blade); + $this->assertStringContainsString('href="https://' . $team . '.test/' . $team . '"', $blade); + $this->assertStringContainsString('href="/' . $team . '.webmanifest"', $blade); + $this->assertContains('Page ' . $team . ' - ' . $team . '', $elements); + $this->assertContains('', $elements); + $this->assertStringContainsString('Page ' . $team . ' - ' . $team . '', $document); + } + + [$subsequent] = parallel([function (): array { + $request = Request::create('http://alias.test/third'); + $request->attributes->set('team', 'third'); + RequestContext::set($request); + + return Head::toArray(); + }]); + $this->assertSame('Team third', $subsequent['title']); + $this->assertNull($subsequent['description']); + } + + public function testCopiedContextsHaveIndependentBuildersSchemasAndStatus(): void + { + $schema = (new Article)->headline('Parent'); + Head::title('Parent')->meta('source', 'parent')->schema($schema)->status(404); + + $copy = fn (string $name, int $status): Closure => function () use ($name, $status): array { + $state = $this->app->make(CurrentHead::class); + $inheritedStatus = $state->status(); + $state->data()->builder(MetaTags::class)->tag('source', $name); + + $schemas = $state->data()->get(Schemas::class); + $stored = (new ReflectionProperty(Schemas::class, 'schemas'))->getValue($schemas); + $copiedSchema = reset($stored); + $identityMatches = array_key_first($stored) === spl_object_hash($copiedSchema); + $copiedSchema->headline($name); + $schemas->schema($copiedSchema); + + Head::title($name)->status($status); + usleep(5000); + + return [Head::toArray(), $inheritedStatus, $state->status(), $identityMatches]; + }; + + [$first, $second] = parallel([$copy('First', 500), $copy('Second', 403)], copyContext: true); + + foreach ([['First', 500, $first], ['Second', 403, $second]] as [$name, $status, $result]) { + [$data, $inheritedStatus, $currentStatus, $identityMatches] = $result; + $this->assertSame(404, $inheritedStatus); + $this->assertSame($status, $currentStatus); + $this->assertSame($name, $data['title']); + $this->assertSame([['key' => 'source', 'content' => $name]], $data['meta']); + $this->assertCount(1, $data['schemas']); + $this->assertSame($name, $data['schemas'][0]['headline']); + $this->assertTrue($identityMatches); + } + + $parent = Head::toArray(); + $this->assertSame('Parent', $parent['title']); + $this->assertSame([['key' => 'source', 'content' => 'parent']], $parent['meta']); + $this->assertSame('Parent', $parent['schemas'][0]['headline']); + $this->assertSame('Parent', $schema->toArray()['headline']); + $this->assertSame(404, $this->app->make(CurrentHead::class)->status()); + } +} diff --git a/tests/Head/Feature/ErrorPagesTest.php b/tests/Head/Feature/ErrorPagesTest.php new file mode 100644 index 0000000000..4b6a573e8a --- /dev/null +++ b/tests/Head/Feature/ErrorPagesTest.php @@ -0,0 +1,166 @@ +make(ExceptionHandler::class); + $handler->renderable(fn (HeadEarlyRenderedException $exception): Response => new Response('', 404)); + } + + public function testLetsErrorHeadBeatTheResolvedPageHead(): void + { + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Acme', suffix: ' - Acme')); + + Head::errors(function (ErrorPages $errors): void { + $errors->defaults(robots: 'noindex, follow'); + + $errors->status( + 404, + title: 'Page Not Found', + description: 'The page could not be found.', + ); + }); + + Route::get('/missing', fn (): string => Head::toHtml(404))->withHead( + title: 'Original Page', + description: 'Original description.', + ); + + $this->get('/missing') + ->assertOk() + ->assertSee('Page Not Found - Acme', false) + ->assertSee('', false) + ->assertSee('', false); + } + + public function testAcceptsHeadBuilderCallbacksForErrorMetadata(): void + { + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Acme', suffix: ' - Acme')); + + Head::errors(function (ErrorPages $errors): void { + $errors->defaults(fn (HeadBuilder $head): HeadBuilder => $head->hiddenFromRobots()); + + $errors->status(404, fn (HeadBuilder $head): HeadBuilder => $head + ->title('Page Not Found') + ->description('The page could not be found.')); + }); + + Route::get('/missing', fn (): string => Head::toHtml(404))->withHead( + title: 'Original Page', + ); + + $this->get('/missing') + ->assertOk() + ->assertSee('Page Not Found - Acme', false) + ->assertSee('', false) + ->assertSee('', false); + } + + public function testAppliesErrorHeadWhenAnAbortedRequestRendersAnErrorView(): void + { + config(['view.paths' => [__DIR__ . '/../Fixtures/views', ...config('view.paths')]]); + + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Acme', suffix: ' - Acme')); + + Head::errors(function (ErrorPages $errors): void { + $errors->defaults(robots: 'noindex, follow'); + + $errors->status(404, title: 'Page Not Found'); + }); + + $this->get('/definitely-missing') + ->assertNotFound() + ->assertSee('Page Not Found - Acme', false) + ->assertSee('', false); + } + + #[DataProvider('exceptionResponses')] + public function testUsesTheActualStatusBeforeInertiaRendersAnException(Closure $makeException): void + { + Head::errors(function (ErrorPages $errors): void { + $errors->status(404, title: 'Page Not Found'); + $errors->status(500, title: 'Server Error'); + }); + Head::resolveErrorsUsing(function (HeadBuilder $head, int $status): void { + $head->description('Error status ' . $status); + }); + + Inertia::handleExceptionsUsing(fn (ExceptionResponse $response): ExceptionResponse => $response->render('Error')); + Route::aliasMiddleware('head.error', function (Request $request, Closure $next) use ($makeException): never { + throw $makeException(); + }); + Route::get('/error-status', fn (): string => 'Unreachable')->middleware('head.error'); + + $response = $this->get('/error-status', ['X-Inertia' => 'true']) + ->assertNotFound() + ->assertJsonPath('component', 'Error'); + + $this->assertContains('Page Not Found', $response->json('props.head')); + $this->assertContains('', $response->json('props.head')); + } + + /** + * Provide exception paths that infer the wrong status or bypass render callbacks. + * + * @return array + */ + public static function exceptionResponses(): array + { + return [ + 'embedded response' => [fn (): Throwable => new HttpResponseException(new Response('', 404))], + 'responsable exception' => [fn (): Throwable => new class extends RuntimeException implements Responsable { + /** + * Render the exception's embedded response. + */ + public function toResponse(Request $request): Response + { + return new Response('', 404); + } + }], + 'renderable exception' => [fn (): Throwable => new class extends RuntimeException { + /** + * Render the exception's custom response. + */ + public function render(Request $request): Response + { + return new Response('', 404); + } + }], + 'earlier application renderer' => [fn (): Throwable => new HeadEarlyRenderedException], + ]; + } +} + +class HeadEarlyRenderedException extends RuntimeException +{ +} diff --git a/tests/Head/Feature/HeadBuilderTest.php b/tests/Head/Feature/HeadBuilderTest.php new file mode 100644 index 0000000000..9706f0f1ff --- /dev/null +++ b/tests/Head/Feature/HeadBuilderTest.php @@ -0,0 +1,100 @@ +title('About') + ->description('About Acme.') + ->schema(fn (SchemaFactory $schema): SchemaObject => $schema->make('webPage')->set('name', 'About')); + + $this->assertFalse($data->isEmpty()); + $this->assertNotNull($data->get(Title::class)); + $this->assertNotNull($data->get(Schemas::class)); + } + + public function testAppliesConditionalDefinitionsWithWhenAndUnless(): void + { + $data = new HeadData; + + (new HeadBuilder($data)) + ->when(true, fn (HeadBuilder $head): HeadBuilder => $head->title('Conditional')) + ->unless(true, fn (HeadBuilder $head): HeadBuilder => $head->description('Skipped')); + + $this->assertNotNull($data->get(Title::class)); + $this->assertFalse($data->isEmpty()); + } + + public function testSupportsConditionalRuntimeDefinitionsThroughTheFacade(): void + { + Head::when(true, fn (HeadManager $head): HeadManager => $head->title('Conditional')); + Head::unless(true, fn (HeadManager $head): HeadManager => $head->description('Skipped')); + + $html = Head::toHtml(); + $this->assertStringContainsString('Conditional', $html); + $this->assertStringNotContainsString('Skipped', $html); + } + + public function testReceivesAHeadBuilderInDefaultsCallbacks(): void + { + Head::defaults(function (HeadBuilder $head): void { + $head->title('Acme', suffix: ' - Acme'); + }); + + Head::title('About'); + + $this->assertStringContainsString('About - Acme', Head::toHtml()); + } + + public function testChainsRuntimeDefinitionsIntoRendering(): void + { + $html = Head::title('About') + ->description('About Acme.') + ->toHtml(); + + $this->assertStringContainsString('About', $html); + $this->assertStringContainsString('', $html); + } + + public function testDirectBuildersUseTheRegisteredSchemaFactory(): void + { + $factory = $this->app->make(SchemaFactory::class); + $factory->register(JobPosting::class); + + (new HeadBuilder(new HeadData))->schema(function (SchemaFactory $resolved) use ($factory): SchemaObject { + $this->assertSame($factory, $resolved); + $this->assertInstanceOf(JobPosting::class, $resolved->make('jobPosting')); + + return $resolved->make('jobPosting')->title('Developer'); + }); + } + + public function testExplicitSchemaFactoryTakesPrecedence(): void + { + $factory = new SchemaFactory; + + (new HeadBuilder(new HeadData, $factory))->schema(function (SchemaFactory $resolved) use ($factory): SchemaObject { + $this->assertSame($factory, $resolved); + + return $resolved->make('webPage')->set('name', 'About'); + }); + } +} diff --git a/tests/Head/Feature/HeadDataTest.php b/tests/Head/Feature/HeadDataTest.php new file mode 100644 index 0000000000..a1ffb1ceff --- /dev/null +++ b/tests/Head/Feature/HeadDataTest.php @@ -0,0 +1,72 @@ +headData()->overlayBuilder(ReadingTime::make($minutes)); + + return $this; + }); + + Head::readingTime(7); + + $html = Head::toHtml(); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + + $this->assertSame(7, Head::toArray()['readingTime']); + } + + public function testResolvesCustomBuilderValuesFromRouteAttributes(): void + { + $this->app->make(TagRegistry::class)->routeAttributeKeys(); + Head::extend(ReadingTime::class); + + Route::get('/article', fn (): string => Head::toHtml()) + ->withHead(extensions: ['readingTime' => 4]); + + $this->get('/article') + ->assertOk() + ->assertSee('', false); + } + + public function testRejectsTagExtensionsThatDoNotExtendTagBuilders(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Head tag extensions must extend'); + Head::extend(stdClass::class); + } + + public function testRejectsBuiltInTagBuilders(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Built-in head tag builders are already registered.'); + Head::extend(Title::class); + } + + public function testRejectsTagExtensionsThatRegisterDuplicateRouteAttributeKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Head tag extensions may not register route attributes already registered by another builder: title.'); + Head::extend(ConflictingRouteAttribute::class); + } +} diff --git a/tests/Head/Feature/HeadManagerTest.php b/tests/Head/Feature/HeadManagerTest.php new file mode 100644 index 0000000000..4b6ba71430 --- /dev/null +++ b/tests/Head/Feature/HeadManagerTest.php @@ -0,0 +1,161 @@ +assertInstanceOf(HtmlString::class, Head::render()); + } + + public function testDefinitionsStayEagerAndRuntimeDefaultsAreLazyAndFresh(): void + { + $eagerCalls = 0; + $runtimeCalls = 0; + + Head::defaults(function (HeadBuilder $head) use (&$eagerCalls): void { + ++$eagerCalls; + $head->title('Static')->description('Static description'); + }); + + Head::resolveDefaultsUsing(function (HeadBuilder $head) use (&$runtimeCalls): void { + ++$runtimeCalls; + $site = CoroutineContext::get('head.test.site'); + $head->title($site); + + if ($site === 'First') { + $head->description('First description'); + } + }); + + $this->assertSame(1, $eagerCalls); + $this->assertSame(0, $runtimeCalls); + + CoroutineContext::set('head.test.site', 'First'); + $first = Head::toArray(); + $this->assertSame('First', $first['title']); + $this->assertSame('First description', $first['description']); + + CoroutineContext::set('head.test.site', 'Second'); + $second = Head::toArray(); + $this->assertSame('Second', $second['title']); + $this->assertSame('Static description', $second['description']); + $this->assertSame(1, $eagerCalls); + $this->assertSame(2, $runtimeCalls); + } + + public function testRuntimeDefaultsCanBeReplacedAndCleared(): void + { + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Static')); + + $manager = Head::resolveDefaultsUsing(fn (HeadBuilder $head): HeadBuilder => $head->title('First')); + $this->assertSame($this->app->make(HeadManager::class), $manager); + $this->assertSame('First', Head::toArray()['title']); + + Head::resolveDefaultsUsing(fn (HeadBuilder $head): HeadBuilder => $head->title('Second')); + $this->assertSame('Second', Head::toArray()['title']); + + Head::resolveDefaultsUsing(null); + $this->assertSame('Static', Head::toArray()['title']); + } + + public function testRuntimeLayersPreserveRoutePageAndErrorPrecedence(): void + { + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head + ->title('Static', suffix: ' - Static') + ->description('Static description')); + Head::resolveDefaultsUsing(fn (HeadBuilder $head): HeadBuilder => $head + ->title('Site', suffix: ' - Site') + ->description('Runtime description')); + Head::errors(function (ErrorPages $errors): void { + $errors->defaults(robots: 'noindex'); + $errors->status(404, title: 'Static error', description: 'Static error description'); + }); + Head::resolveErrorsUsing(fn (HeadBuilder $head, int $status): HeadBuilder => $head->title('Runtime error ' . $status)); + + Route::withHead(title: 'Group', description: 'Group description')->group(function (): void { + Route::get('/head-precedence', function (): array { + $route = Head::toArray(); + Head::title('Page'); + + return [ + 'route' => $route, + 'page' => Head::toArray(), + 'error' => Head::toArray(404), + ]; + })->withHead(title: 'Route'); + }); + + $this->getJson('/head-precedence') + ->assertOk() + ->assertJsonPath('route.title', 'Route - Site') + ->assertJsonPath('route.description', 'Group description') + ->assertJsonPath('page.title', 'Page - Site') + ->assertJsonPath('error.title', 'Runtime error 404 - Site') + ->assertJsonPath('error.description', 'Static error description') + ->assertJsonPath('error.robots', 'noindex'); + } + + public function testRuntimeErrorsUseTheEffectiveStatusEvenWithoutAStaticDefinition(): void + { + $statuses = []; + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Default')); + Head::errors(fn (ErrorPages $errors): ErrorPages => $errors->status(404, title: 'Missing')); + Head::resolveErrorsUsing(function (HeadBuilder $head, int $status) use (&$statuses): void { + $statuses[] = $status; + $head->description('Status ' . $status); + }); + + $this->assertNull(Head::toArray()['description']); + $this->assertSame([], $statuses); + + Head::status(404); + $explicit = Head::toArray(201); + $this->assertSame('Default', $explicit['title']); + $this->assertSame('Status 201', $explicit['description']); + + $current = Head::toArray(); + $this->assertSame('Missing', $current['title']); + $this->assertSame('Status 404', $current['description']); + $this->assertSame([201, 404], $statuses); + } + + public function testRuntimeErrorsCanBeReplacedAndCleared(): void + { + Head::errors(fn (ErrorPages $errors): ErrorPages => $errors->status(404, title: 'Static')); + Head::resolveErrorsUsing(fn (HeadBuilder $head, int $status): HeadBuilder => $head->title('First ' . $status)); + $this->assertSame('First 404', Head::toArray(404)['title']); + + Head::resolveErrorsUsing(fn (HeadBuilder $head, int $status): HeadBuilder => $head->title('Second ' . $status)); + $this->assertSame('Second 404', Head::toArray(404)['title']); + + Head::resolveErrorsUsing(null); + $this->assertSame('Static', Head::toArray(404)['title']); + } + + public function testFlushClearsPageDataAndStatusWhileKeepingBootDefaults(): void + { + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Static')); + Head::resolveDefaultsUsing(fn (HeadBuilder $head): HeadBuilder => $head->description('Runtime default')); + Head::errors(fn (ErrorPages $errors): ErrorPages => $errors->status(404, title: 'Missing')); + Head::title('Page')->status(404); + $this->assertSame('Missing', Head::toArray()['title']); + + Head::flush(); + $resolved = Head::toArray(); + $this->assertSame('Static', $resolved['title']); + $this->assertSame('Runtime default', $resolved['description']); + } +} diff --git a/tests/Head/Feature/HeadServiceProviderTest.php b/tests/Head/Feature/HeadServiceProviderTest.php new file mode 100644 index 0000000000..8587f5a884 --- /dev/null +++ b/tests/Head/Feature/HeadServiceProviderTest.php @@ -0,0 +1,87 @@ + + */ + protected function getPackageProviders(Application $app): array + { + return [HeadServiceProvider::class]; + } + + /** + * Resolve the handler before Head registers its status callback. + */ + protected function resolveApplicationExceptionHandler(Application $app): void + { + parent::resolveApplicationExceptionHandler($app); + + $app->make(ExceptionHandler::class); + } + + public function testRegistersTheHeadManager(): void + { + $this->assertInstanceOf(HeadManager::class, app('head')); + $this->assertSame(app('head'), app(HeadManager::class)); + $this->assertSame(app('head.schema'), app(SchemaFactory::class)); + } + + public function testRegistersTheTagRegistry(): void + { + $this->assertInstanceOf(TagRegistry::class, app(TagRegistry::class)); + } + + public function testRegistersTheHeadFacade(): void + { + $this->assertInstanceOf(HeadManager::class, Head::getFacadeRoot()); + } + + public function testRegistersTheBladeDirective(): void + { + $compiled = app('blade.compiler')->compileString('@head'); + + $this->assertStringContainsString("app('head')->renderForView()", $compiled); + } + + public function testDoesNotEnableInertiaWhenItsClassesExistWithoutItsProvider(): void + { + $this->assertTrue(class_exists(InertiaServiceProvider::class)); + $this->assertFalse($this->app->providerIsLoaded(InertiaServiceProvider::class)); + $this->assertFalse($this->app->bound(Gateway::class)); + $this->assertFalse($this->app->make(Kernel::class)->hasMiddleware(ShareHead::class)); + + Head::title('Blade page'); + $this->assertSame('Blade page', Head::renderForView()->toHtml()); + } + + public function testCapturesExceptionStatusWhenTheHandlerWasAlreadyResolved(): void + { + config(['view.paths' => [__DIR__ . '/../Fixtures/views', ...config('view.paths')]]); + Head::errors(fn (ErrorPages $errors): ErrorPages => $errors->status(404, title: 'Early handler')); + + $this->get('/missing-with-early-handler') + ->assertNotFound() + ->assertSee('Early handler', false); + } +} diff --git a/tests/Head/Feature/InertiaCustomPropTest.php b/tests/Head/Feature/InertiaCustomPropTest.php new file mode 100644 index 0000000000..cf49d8764d --- /dev/null +++ b/tests/Head/Feature/InertiaCustomPropTest.php @@ -0,0 +1,39 @@ +booting(fn (): HeadManager => Head::inertia(prop: '_head')); + } + + public function testHeadElementsAreSharedUnderTheConfiguredProp(): void + { + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard', [ + 'head' => 'An unrelated app prop', + ]))->withHead(title: 'Dashboard'); + + $this->get('/dashboard', [Header::INERTIA => 'true']) + ->assertOk() + ->assertJsonPath('props.head', 'An unrelated app prop') + ->assertJsonPath('props._head.0', 'Dashboard'); + } +} diff --git a/tests/Head/Feature/InertiaTest.php b/tests/Head/Feature/InertiaTest.php new file mode 100644 index 0000000000..595718bc96 --- /dev/null +++ b/tests/Head/Feature/InertiaTest.php @@ -0,0 +1,444 @@ +assertTrue($this->app->make(Kernel::class)->hasMiddleware(ShareHead::class)); + } + + public function testSharesRenderedHeadElementsWithInertiaPageObjects(): void + { + Head::defaults(fn (HeadBuilder $head): HeadBuilder => $head->title('Acme', suffix: ' - Acme')); + + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard', [ + 'user' => 'Taylor', + ]))->withHead( + title: 'Dashboard', + description: 'Your application overview.', + ); + + $response = $this->get('/dashboard', [Header::INERTIA => 'true']) + ->assertOk() + ->assertJsonPath('component', 'Dashboard') + ->assertJsonPath('props.user', 'Taylor'); + + $elements = $response->json('props.' . HeadManager::INERTIA_PROP); + + $this->assertIsArray($elements); + $this->assertContains('Dashboard - Acme', $elements); + $this->assertContains('', $elements); + } + + public function testRestoresSharedHeadElementsAfterInertiaFlushesItsSharedProps(): void + { + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard')) + ->withHead(title: 'Dashboard'); + + $headers = [Header::INERTIA => 'true']; + + $this->get('/dashboard', $headers) + ->assertOk() + ->assertJsonPath( + 'props.' . HeadManager::INERTIA_PROP . '.0', + 'Dashboard', + ); + + Inertia::flushShared(); + + $this->get('/dashboard', $headers) + ->assertOk() + ->assertJsonPath( + 'props.' . HeadManager::INERTIA_PROP . '.0', + 'Dashboard', + ); + } + + public function testSharesStableSemanticInertiaKeysForEachHeadElement(): void + { + Route::get('/lean', fn (): Response => Inertia::render('Page'))->withHead( + description: 'Lean page.', + ); + + Route::get('/rich', fn (): Response => Inertia::render('Page'))->withHead( + title: 'Rich page', + description: 'Rich page.', + preload: [['href' => '/fonts/inter.woff2', 'as' => 'font']], + ogImage: 'https://example.com/rich.jpg', + ); + + $lean = $this->get('/lean', [Header::INERTIA => 'true'])->json('props.' . HeadManager::INERTIA_PROP); + $rich = $this->get('/rich', [Header::INERTIA => 'true'])->json('props.' . HeadManager::INERTIA_PROP); + + $this->assertContains('', $lean); + $this->assertContains('Rich page', $rich); + $this->assertContains('', $rich); + + foreach ([...$lean, ...$rich] as $element) { + $this->assertStringContainsString(' data-inertia="', $element); + } + } + + public function testKeepsHeadElementsOffTheWireDuringInertiaPartialReloads(): void + { + $resolutions = 0; + Head::resolveDefaultsUsing(function (HeadBuilder $head) use (&$resolutions): void { + ++$resolutions; + $head->description('Runtime metadata'); + }); + + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard', [ + 'user' => 'Taylor', + ]))->withHead(title: 'Dashboard'); + + $this->get('/dashboard', [ + Header::INERTIA => 'true', + Header::PARTIAL_COMPONENT => 'Dashboard', + Header::PARTIAL_ONLY => 'user', + ]) + ->assertOk() + ->assertJsonPath('props.user', 'Taylor') + ->assertJsonMissingPath('props.' . HeadManager::INERTIA_PROP); + + $this->assertSame(0, $resolutions); + } + + public function testKeepsInertiaGlobalsOutOfInertiaPageObjects(): void + { + Head::inertiaGlobals(fn (HeadBuilder $head): HeadBuilder => $head + ->viewport('width=device-width, initial-scale=1') + ->icon('/favicon.svg', type: 'image/svg+xml')); + + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard'))->withHead( + title: 'Dashboard', + description: 'Dashboard overview.', + ); + + $elements = $this->get('/dashboard', [Header::INERTIA => 'true']) + ->assertOk() + ->json('props.' . HeadManager::INERTIA_PROP); + + $this->assertContains('Dashboard', $elements); + $this->assertContains('', $elements); + + $html = implode("\n", $elements); + $this->assertStringNotContainsString('data-inertia="viewport"', $html); + $this->assertStringNotContainsString('data-inertia="link:icon', $html); + } + + public function testRendersInertiaGlobalsAsStaticTagsAndPageTagsAsInertiaManagedTagsInTheInertiaRootView(): void + { + Head::inertiaGlobals(fn (HeadBuilder $head): HeadBuilder => $head + ->viewport('width=device-width, initial-scale=1') + ->icon('/favicon.svg', type: 'image/svg+xml')); + + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard'))->withHead( + title: 'Dashboard', + description: 'Dashboard overview.', + ); + + $this->get('/dashboard') + ->assertOk() + ->assertSee('', false) + ->assertSee('', false) + ->assertSee('Dashboard', false) + ->assertSee('', false) + ->assertDontSee('assertDontSee('Dashboard')); + $this->assertSame(1, substr_count($html, '')); + $this->assertStringContainsString('', $html); + } + + public function testPreservesTheDecoratedInertiaSsrGatewayCapabilities(): void + { + $gateway = new class implements ConfiguresSsrRequests, DisablesSsr, ExcludesSsrPaths, Gateway, HasHealthCheck { + public Closure|bool|null $disabled = null; + + /** @var array */ + public array $except = []; + + public ?Closure $configurator = null; + + public bool $healthy = true; + + /** + * Return no SSR response. + * + * @param array $page + */ + public function dispatch(array $page): ?SsrResponse + { + return null; + } + + /** + * Record the SSR disabling condition. + */ + public function disable(Closure|bool $condition): void + { + $this->disabled = $condition; + } + + /** + * Record excluded SSR paths. + * + * @param array|string $paths + */ + public function except(array|string $paths): void + { + $this->except = [ + ...$this->except, + ...(is_array($paths) ? $paths : [$paths]), + ]; + } + + /** + * Record the request configurator. + */ + public function configureRequestUsing(?Closure $callback = null): void + { + $this->configurator = $callback; + } + + /** + * Return the configured health result. + */ + public function isHealthy(): bool + { + return $this->healthy; + } + }; + + $this->app->bind(Gateway::class, fn (): Gateway => $gateway); + + $decorated = $this->app->make(Gateway::class); + $condition = fn (): bool => true; + + $this->assertInstanceOf(DisablesSsr::class, $decorated); + $this->assertInstanceOf(ExcludesSsrPaths::class, $decorated); + $this->assertInstanceOf(HasHealthCheck::class, $decorated); + $this->assertInstanceOf(ConfiguresSsrRequests::class, $decorated); + + Inertia::disableSsr($condition); + Inertia::withoutSsr(['/admin', '/internal']); + + $this->assertSame($condition, $gateway->disabled); + $this->assertSame(['/admin', '/internal'], $gateway->except); + $this->assertTrue($decorated->isHealthy()); + $gateway->healthy = false; + $this->assertFalse($decorated->isHealthy()); + $this->assertNull($decorated->dispatch([])); + + $configurator = static function (): void {}; + Inertia::configureSsrRequestUsing($configurator); + $this->assertSame($configurator, $gateway->configurator); + Inertia::configureSsrRequestUsing(null); + $this->assertNull($gateway->configurator); + } + + #[DataProvider('unsupportedCapabilities')] + public function testReportsWhenTheDecoratedInertiaSsrGatewayLacksCapabilities(string $capability, string $message): void + { + $this->app->bind(Gateway::class, fn (): Gateway => new class implements Gateway { + /** + * Return no SSR response. + * + * @param array $page + */ + public function dispatch(array $page): ?SsrResponse + { + return null; + } + }); + + $decorated = $this->app->make(Gateway::class); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage($message); + + match ($capability) { + 'disable' => Inertia::disableSsr(), + 'except' => Inertia::withoutSsr('/admin'), + 'configure' => Inertia::configureSsrRequestUsing(), + 'health' => $decorated->isHealthy(), + }; + } + + /** + * Supply unsupported gateway capabilities and their diagnostics. + * + * @return iterable + */ + public static function unsupportedCapabilities(): iterable + { + yield 'disable' => ['disable', 'The configured SSR gateway does not support disabling server-side rendering conditionally.']; + yield 'paths' => ['except', 'The configured SSR gateway does not support excluding paths from server-side rendering.']; + yield 'request configuration' => ['configure', 'The configured SSR gateway does not support configuring server-side rendering requests.']; + yield 'health check' => ['health', 'The configured SSR gateway does not support health checks.']; + } + + public function testDeduplicatesHypervelHeadFromTheInertiaSsrHeadWhilePreservingOtherSsrElements(): void + { + $this->app->bind(Gateway::class, fn (): Gateway => new class implements Gateway { + /** + * Render page-managed and client-only SSR elements. + * + * @param array $page + */ + public function dispatch(array $page): ?SsrResponse + { + return new SsrResponse( + implode("\n", [ + ...$page['props'][HeadManager::INERTIA_PROP], + '', + ]), + '
SSR BODY
', + ); + } + }); + + Head::inertiaGlobals(fn (HeadBuilder $head): HeadBuilder => $head + ->viewport('width=device-width, initial-scale=1')); + + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard'))->withHead( + title: 'Dashboard', + description: 'Dashboard overview.', + ); + + $html = $this->get('/dashboard')->assertOk()->getContent(); + + $this->assertSame(1, substr_count($html, 'Dashboard')); + $this->assertSame(1, substr_count($html, '')); + $this->assertStringContainsString('', $html); + $this->assertSame(1, substr_count($html, '')); + $this->assertStringContainsString('
SSR BODY
', $html); + } + + public function testDeduplicatesTheSsrHeadEvenWhenTheInertiaHeadComponentRendersBeforeTheDirective(): void + { + $this->app->bind(Gateway::class, fn (): Gateway => new class implements Gateway { + /** + * Render page-managed and client-only SSR elements. + * + * @param array $page + */ + public function dispatch(array $page): ?SsrResponse + { + return new SsrResponse( + implode("\n", [ + ...$page['props'][HeadManager::INERTIA_PROP], + '', + ]), + '
SSR BODY
', + ); + } + }); + + Inertia::setRootView('app-inertia-head-first'); + + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard'))->withHead( + title: 'Dashboard', + description: 'Dashboard overview.', + ); + + $html = $this->get('/dashboard')->assertOk()->getContent(); + + $this->assertSame(1, substr_count($html, 'Dashboard')); + $this->assertSame(1, substr_count($html, '')); + $this->assertSame(1, substr_count($html, '')); + $this->assertStringContainsString('
SSR BODY
', $html); + } + + public function testRendersInertiaManagedTagsWhenTheDirectiveRunsOutsideTheRootViewScope(): void + { + Inertia::setRootView('app-nested'); + + Route::get('/dashboard', fn (): Response => Inertia::render('Dashboard'))->withHead( + title: 'Dashboard', + ); + + $this->get('/dashboard') + ->assertOk() + ->assertSee('Dashboard', false); + } + + public function testPreservesTheWrappedResponseAndUsesTheCustomHeadProp(): void + { + Head::inertia('_head'); + $title = 'Page'; + $client = ''; + $response = new SsrResponse($title . $client, '
Body
'); + $gateway = new class($response) implements Gateway { + /** + * Retain the original immutable SSR response. + */ + public function __construct(public readonly SsrResponse $response) + { + } + + /** + * Return the retained response. + * + * @param array $page + */ + public function dispatch(array $page): ?SsrResponse + { + return $this->response; + } + }; + $this->app->bind(Gateway::class, fn (): Gateway => $gateway); + + $rendered = $this->app->make(Gateway::class)->dispatch(['props' => ['_head' => [$title]]]); + + $this->assertNotSame($response, $rendered); + $this->assertSame($title . $client, $response->head); + $this->assertSame($client, $rendered->head); + $this->assertSame($response->body, $rendered->body); + } +} diff --git a/tests/Head/Feature/Rendering/HeadRendererTest.php b/tests/Head/Feature/Rendering/HeadRendererTest.php new file mode 100644 index 0000000000..f5ce0dcc35 --- /dev/null +++ b/tests/Head/Feature/Rendering/HeadRendererTest.php @@ -0,0 +1,148 @@ +title('Acme', suffix: ' - Acme') + ->description('Build something great.') + ->canonical() + ->og(siteName: 'Acme', type: OgType::Website) + ->twitter(card: TwitterCard::SummaryWithLargeImage) + ->preconnect('https://fonts.example.com'); + }); + + Head::title('About') + ->description('About Acme.') + ->canonical('http://example.com/about/') + ->preload('/fonts/inter.woff2', as: 'font', crossorigin: true) + ->prefetch('/images/next.webp') + ->dnsPrefetch('https://analytics.example.com') + ->paginate(new LengthAwarePaginator(['post'], total: 30, perPage: 10, currentPage: 2, options: ['path' => '/posts'])) + ->alternates(['en' => 'https://example.com/about', 'fr' => 'https://example.com/fr/about']) + ->feed('/feed', title: 'Acme RSS') + ->schema(Schema::webPage()->name('About')->url('https://example.com/about')); + + $html = Head::toHtml(); + + $this->assertStringContainsString('About - Acme', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>', $html); + $this->assertStringContainsString('rel="prefetch" href="/images/next.webp">', $html); + $this->assertStringContainsString('rel="preconnect" href="https://fonts.example.com">', $html); + $this->assertStringContainsString('rel="dns-prefetch" href="https://analytics.example.com">', $html); + $this->assertStringContainsString('rel="prev" href="/posts?page=1">', $html); + $this->assertStringContainsString('rel="next" href="/posts?page=3">', $html); + $this->assertStringContainsString('rel="alternate" hreflang="fr" href="https://example.com/fr/about">', $html); + $this->assertStringContainsString('rel="alternate" type="application/rss+xml" title="Acme RSS" href="/feed">', $html); + $this->assertStringContainsString('"@type":"WebPage"', $html); + } + + public function testDoesNotRenderInertiaKeysInBladeHtmlOutput(): void + { + Head::title('Dashboard') + ->description('Dashboard overview.'); + + $html = Head::toHtml(); + $this->assertStringContainsString('Dashboard', $html); + $this->assertStringContainsString('', $html); + $this->assertStringNotContainsString('data-inertia', $html); + } + + public function testRendersInertiaGlobalsInBladeHtmlOutput(): void + { + Head::inertiaGlobals(fn (HeadBuilder $head): HeadBuilder => $head + ->viewport('width=device-width, initial-scale=1') + ->icon('/favicon.svg', type: 'image/svg+xml')); + + Head::title('Dashboard'); + + $html = Head::toHtml(); + $this->assertStringContainsString('Dashboard', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringNotContainsString('data-inertia', $html); + } + + public function testDeduplicatesInertiaElementsInOneFinalizedListUsingTheLastValueForEachOwnershipKey(): void + { + Head::description('Dedicated description.') + ->meta('description', 'Generic description.'); + + $elements = Head::toInertiaElements(); + + $this->assertNotContains('', $elements); + $this->assertContains('', $elements); + $this->assertCount(1, array_filter( + $elements, + fn (string $element): bool => str_contains($element, 'data-inertia="description"'), + )); + } + + public function testRemovesExactFinalizedInertiaElementsFromAnSsrHead(): void + { + $html = implode("\n", [ + 'Hypervel title', + '', + '', + ]); + + $head = app(HeadRenderer::class)->withoutInertiaElements($html, [ + 'Hypervel title', + '', + ]); + + $this->assertStringNotContainsString('data-inertia="title"', $head); + $this->assertStringNotContainsString('data-inertia="description"', $head); + $this->assertStringContainsString('', $head); + } + + public function testGroupsLinkBuildersUnderTheLinksKeyWhenSerializedToAnArray(): void + { + Head::title('About') + ->preload('/fonts/inter.woff2', as: 'font') + ->paginate(new LengthAwarePaginator(['post'], total: 30, perPage: 10, currentPage: 2, options: ['path' => '/posts'])) + ->alternates(['fr' => 'https://example.com/fr/about']) + ->feed('/feed', title: 'Acme RSS') + ->link('manifest', '/site.webmanifest'); + + $array = Head::toArray(); + + $this->assertSame('About', $array['title']); + $this->assertSame([], $array['openGraph']); + $this->assertEqualsCanonicalizing([ + 'performance', 'pagination', 'alternates', 'feeds', 'generic', + ], array_keys($array['links'])); + $this->assertSame('/fonts/inter.woff2', $array['links']['performance']['preload'][0]['href']); + $this->assertSame(['prev' => '/posts?page=1', 'next' => '/posts?page=3'], $array['links']['pagination']); + $this->assertSame(['fr' => 'https://example.com/fr/about'], $array['links']['alternates']); + } + + public function testTitleAndMetaTitleHaveIndependentInertiaOwnership(): void + { + Head::title('Page title')->meta('title', 'Metadata title'); + + $this->assertSame([ + 'Page title', + '', + ], Head::toInertiaElements()); + } +} diff --git a/tests/Head/Feature/Rendering/TagRendererTest.php b/tests/Head/Feature/Rendering/TagRendererTest.php new file mode 100644 index 0000000000..78f22aa437 --- /dev/null +++ b/tests/Head/Feature/Rendering/TagRendererTest.php @@ -0,0 +1,95 @@ +assertSame('data-inertia="title"', $tags->attributes(['data-inertia' => 'title'])); + $this->assertSame('crossorigin', $tags->attributes(['crossorigin' => true])); + $this->assertSame('aria-label="Close" tabindex="0"', $tags->attributes(['aria-label' => 'Close', 'tabindex' => '0'])); + $this->assertSame('@click="close" -custom="value"', $tags->attributes(['@click' => 'close', '-custom' => 'value'])); + $this->assertSame('1custom="value" café="yes"', $tags->attributes(['1custom' => 'value', 'café' => 'yes'])); + $this->assertSame('foo&bar="value"', $tags->attributes(['foo&bar' => 'value'])); + $this->assertSame('123="value"', $tags->attributes(['123' => 'value'])); + } + + #[DataProvider('invalidAttributeNames')] + public function testRejectsInvalidHtmlAttributeNames(string $name): void + { + $tags = new TagRenderer; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid HTML attribute name.'); + $tags->attributes([$name => 'value']); + } + + /** + * Supply invalid HTML attribute names. + * + * @return iterable + */ + public static function invalidAttributeNames(): iterable + { + yield 'empty' => ['']; + yield 'space' => ['onerror x']; + yield 'equals' => ['foo=bar']; + yield 'double quote' => ['foo"bar']; + yield 'single quote' => ["foo'bar"]; + yield 'slash' => ['foo/bar']; + yield 'greater than' => ['foo>bar']; + yield 'control' => ["foo\nbar"]; + yield 'invalid utf-8' => ["foo\xC3\x28bar"]; + } + + public function testValidatesAttributeNamesSuppliedDirectlyToTagMethods(): void + { + $tags = new TagRenderer; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid HTML attribute name.'); + $tags->metaWithAttributes('name onerror', 'description', ['content' => 'Value']); + } + + public function testRejectsCaseInsensitiveDuplicateAttributes(): void + { + $tags = new TagRenderer; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Duplicate HTML attribute name [REL].'); + $tags->linkWithAttributes('stylesheet', [ + 'REL' => 'alternate', + 'href' => '/theme.css', + ]); + } + + public function testJsonLdKeysAreStableAndExplicitKeysArePreserved(): void + { + $schema = ['@type' => 'WebPage', 'name' => 'Café & "News"']; + $first = (new TagRenderer)->withInertiaAttributes()->jsonLd($schema); + $second = (new TagRenderer)->withInertiaAttributes()->jsonLd($schema); + + $this->assertSame($first, $second); + $this->assertStringContainsString('data-inertia="schema:', $first); + $this->assertStringContainsString('Café \u0026 \u0022News\u0022', $first); + $this->assertStringContainsString('data-inertia="custom-schema"', (new TagRenderer(true))->jsonLd($schema, 'custom-schema')); + } + + public function testJsonLdRejectsInvalidUtf8(): void + { + $this->expectException(JsonException::class); + + (new TagRenderer)->jsonLd(['name' => "\xC3\x28"]); + } +} diff --git a/tests/Head/Feature/Routing/RouteHeadTest.php b/tests/Head/Feature/Routing/RouteHeadTest.php new file mode 100644 index 0000000000..34a3a91e46 --- /dev/null +++ b/tests/Head/Feature/Routing/RouteHeadTest.php @@ -0,0 +1,221 @@ +title('Acme', suffix: ' - Acme') + ->description('Default description.'); + }); + + Route::withHead(description: 'Admin description.', robots: 'noindex, nofollow') + ->prefix('admin') + ->group(function (): void { + Route::get('/dashboard', function (): string { + Head::title('Runtime dashboard'); + + return Head::toHtml(); + })->withHead(title: 'Dashboard'); + }); + + $this->get('/admin/dashboard') + ->assertOk() + ->assertSee('Runtime dashboard - Acme', false) + ->assertSee('', false) + ->assertSee('', false); + } + + public function testKeepsRouteGroupHeadMetadataAsASeparateCascadeLayer(): void + { + Route::withHead( + description: 'Admin reports', + og: ['siteName' => 'Acme'], + ) + ->prefix('admin') + ->group(function (): void { + Route::get('/dashboard', fn (): string => Head::toHtml()) + ->withHead(title: 'Dashboard', og: ['title' => 'Dashboard']); + }); + + $this->get('/admin/dashboard') + ->assertOk() + ->assertSee('Dashboard', false) + ->assertSee('', false) + ->assertSee('', false) + ->assertSee('', false); + } + + public function testStoresCacheFriendlyHeadMetadataOnViewAndControllerRoutes(): void + { + $view = Route::view('/contact', 'contact')->withHead( + title: 'Contact', + description: 'Get in touch.', + ); + + $controller = Route::get('/about', fn (): string => 'About')->withHead( + title: 'About', + ); + + $this->assertSame([[ + 'title' => 'Contact', + 'description' => 'Get in touch.', + ]], array_values($view->getMetadata(RouteAttributeParser::HEAD))); + $this->assertSame([[ + 'title' => 'About', + ]], array_values($controller->getMetadata(RouteAttributeParser::HEAD))); + } + + public function testStoresHeadMetadataOnResourceAndSingletonRoutes(): void + { + Route::resource('posts', 'PostController')->withHead( + robots: 'index, follow', + ); + + Route::singleton('profile', 'ProfileController')->withHead( + title: 'Your Profile', + ); + + $posts = Route::getRoutes()->getByName('posts.index'); + $profile = Route::getRoutes()->getByName('profile.show'); + + $this->assertSame([[ + 'robots' => 'index, follow', + ]], array_values($posts?->getMetadata(RouteAttributeParser::HEAD))); + $this->assertSame([[ + 'title' => 'Your Profile', + ]], array_values($profile?->getMetadata(RouteAttributeParser::HEAD))); + } + + public function testParsesRouteHeadDataUsingTheFluentFieldShapes(): void + { + Route::get('/product', fn (): string => Head::toHtml())->withHead( + title: ['value' => 'Product', 'suffix' => ' - Store'], + canonical: ['auto' => true, 'forceHttps' => false], + og: ['type' => OgType::Website, 'image' => 'https://example.com/og.jpg'], + ogImage: [ + ['url' => 'https://example.com/structured.jpg', 'alt' => 'Structured image', 'width' => 1200, 'type' => ImageType::Jpeg], + ], + twitter: ['card' => TwitterCard::SummaryWithLargeImage], + twitterImage: ['url' => 'https://example.com/twitter.jpg', 'alt' => 'Twitter image'], + meta: ['product:price:amount' => '99.00'], + link: [ + ['rel' => 'manifest', 'href' => '/manifest.json'], + ], + ); + + $this->get('/product') + ->assertOk() + ->assertSee('Product - Store', false) + ->assertSee('', false) + ->assertSee('', false) + ->assertSee('property="og:image" content="https://example.com/og.jpg">', false) + ->assertSee('property="og:image:alt" content="Structured image">', false) + ->assertSee('property="og:image:type" content="image/jpeg">', false) + ->assertSee('property="og:image:width" content="1200">', false) + ->assertSee('', false) + ->assertSee('', false) + ->assertSee('', false) + ->assertSee('rel="manifest" href="/manifest.json">', false); + } + + public function testDoesNotAcceptSnakeCaseRouteHeadDataAliases(): void + { + Route::get('/legacy-inputs', fn (): string => Head::toHtml())->withHead( + canonical: ['auto' => true, 'force_https' => false], + og: ['title' => 'Legacy', 'site_name' => 'Legacy'], + ogImage: [ + ['url' => 'https://example.com/image.jpg', 'secure_url' => 'https://secure.example.com/image.jpg'], + ], + ); + + $this->get('/legacy-inputs') + ->assertOk() + ->assertSee('', false) + ->assertSee('property="og:image" content="https://example.com/image.jpg">', false) + ->assertDontSee('', false) + ->assertDontSee('property="og:image:secure_url" content="https://secure.example.com/image.jpg">', false); + } + + public function testParsesSingleRepeatableRouteHeadDataValues(): void + { + Route::get('/assets', fn (): string => Head::toHtml())->withHead( + preload: ['href' => '/fonts/inter.woff2', 'as' => 'font', 'crossorigin' => true], + prefetch: '/images/next.webp', + preconnect: ['href' => 'https://fonts.example.com', 'crossorigin' => true], + dnsPrefetch: 'https://analytics.example.com', + feed: ['href' => '/feed.atom', 'title' => 'Acme Atom', 'type' => 'atom'], + ); + + $this->get('/assets') + ->assertOk() + ->assertSee('rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>', false) + ->assertSee('rel="prefetch" href="/images/next.webp">', false) + ->assertSee('rel="preconnect" href="https://fonts.example.com" crossorigin>', false) + ->assertSee('rel="dns-prefetch" href="https://analytics.example.com">', false) + ->assertSee('rel="alternate" type="application/atom+xml" title="Acme Atom" href="/feed.atom">', false); + } + + public function testThrowsForUnknownRouteHeadDataKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown route head attribute [heading].'); + Route::get('/unknown-head-key', fn (): string => Head::toHtml())->metadata([ + RouteAttributeParser::HEAD => [ + 'layer' => ['heading' => 'Dashboard'], + ], + ]); + + $this->withoutExceptionHandling()->get('/unknown-head-key'); + } + + public function testThrowsForInvalidValuesOnKnownRouteHeadDataKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid value for route head attribute [ogImage].'); + Route::get('/invalid-head-value', fn (): string => Head::toHtml())->withHead( + ogImage: ['alt' => 'Missing URL'], + ); + + $this->withoutExceptionHandling()->get('/invalid-head-value'); + } + + public function testThrowsWhenRouteHeadDataTriesToSuppressCanonicalUrls(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid value for route head attribute [canonical].'); + Route::get('/invalid-canonical', fn (): string => Head::toHtml())->metadata([ + RouteAttributeParser::HEAD => [ + 'layer' => ['canonical' => false], + ], + ]); + + $this->withoutExceptionHandling()->get('/invalid-canonical'); + } + + public function testThrowsWhenRouteCanonicalDataUsesTheRemovedNoneOption(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid value for route head attribute [canonical].'); + Route::get('/invalid-canonical-none', fn (): string => Head::toHtml())->withHead( + canonical: ['none' => true], + ); + + $this->withoutExceptionHandling()->get('/invalid-canonical-none'); + } +} diff --git a/tests/Head/Feature/Routing/WithHeadTest.php b/tests/Head/Feature/Routing/WithHeadTest.php new file mode 100644 index 0000000000..2ff6bca31e --- /dev/null +++ b/tests/Head/Feature/Routing/WithHeadTest.php @@ -0,0 +1,113 @@ +name('contact') + ->withHead( + title: 'Contact Us', + description: 'Get in touch.', + ); + + $this->assertInstanceOf(RoutingRoute::class, $route); + $this->assertSame('contact', $route->getName()); + $this->assertSame([[ + 'title' => 'Contact Us', + 'description' => 'Get in touch.', + ]], array_values($route->getMetadata(RouteAttributeParser::HEAD))); + } + + public function testDefinesHeadMetadataAtTheLeadingGroupPosition(): void + { + Route::withHead(description: 'Admin description.', robots: 'noindex, nofollow') + ->prefix('admin') + ->group(function (): void { + Route::get('/dashboard', fn (): string => Head::toHtml()) + ->withHead(title: 'Dashboard'); + }); + + $this->get('/admin/dashboard') + ->assertOk() + ->assertSee('Dashboard', false) + ->assertSee('', false) + ->assertSee('', false); + } + + public function testDefinesHeadMetadataAfterOtherGroupAttributes(): void + { + Route::prefix('admin') + ->withHead(og: ['siteName' => 'Acme']) + ->name('admin.') + ->group(function (): void { + Route::get('/reports', fn (): string => Head::toHtml()) + ->name('reports') + ->withHead(title: 'Reports', og: ['title' => 'Reports']); + }); + + $this->get('/admin/reports') + ->assertOk() + ->assertSee('Reports', false) + ->assertSee('', false) + ->assertSee('', false); + } + + public function testDefinesHeadMetadataOnResourceAndSingletonRoutes(): void + { + Route::resource('posts', 'PostController')->withHead( + robots: 'index, follow', + ); + + Route::singleton('profile', 'ProfileController')->withHead( + title: 'Your Profile', + ); + + $posts = Route::getRoutes()->getByName('posts.index'); + $profile = Route::getRoutes()->getByName('profile.show'); + + $this->assertSame([[ + 'robots' => 'index, follow', + ]], array_values($posts?->getMetadata(RouteAttributeParser::HEAD))); + $this->assertSame([[ + 'title' => 'Your Profile', + ]], array_values($profile?->getMetadata(RouteAttributeParser::HEAD))); + } + + public function testStoresCacheFriendlyMetadataIncludingExtensionAttributes(): void + { + $route = Route::get('/article', fn (): string => 'Article')->withHead( + title: 'Article', + extensions: ['readingTime' => 4], + ); + + $this->assertSame([[ + 'title' => 'Article', + 'readingTime' => 4, + ]], array_values($route->getMetadata(RouteAttributeParser::HEAD))); + + $route->prepareForSerialization(); + $restored = unserialize(serialize($route)); + $this->assertSame($route->getMetadata(RouteAttributeParser::HEAD), $restored->getMetadata(RouteAttributeParser::HEAD)); + } + + public function testRejectsUnknownNamedWithHeadArguments(): void + { + $arguments = ['heading' => 'Dashboard']; + + $this->expectException(Error::class); + $this->expectExceptionMessage('Unknown named parameter $heading'); + Route::get('/unknown', fn (): string => 'Unknown')->withHead(...$arguments); + } +} diff --git a/tests/Head/Feature/Schema/SchemaTest.php b/tests/Head/Feature/Schema/SchemaTest.php new file mode 100644 index 0000000000..475a158903 --- /dev/null +++ b/tests/Head/Feature/Schema/SchemaTest.php @@ -0,0 +1,215 @@ +headline('Introducing Hypervel Head') + ->description('A fluent API for the document head.') + ->author(Schema::person()->name('Taylor Otwell')) + ->publishedAt('2026-05-13') + ->modifiedAt('2026-05-14') + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('') + ); + + $html = Head::toHtml(); + $this->assertStringNotContainsString('', $html); + $this->assertStringContainsString('\u003C/script\u003E\u003Cscript\u003Ealert(1)\u003C/script\u003E', $html); + } + + public function testResolvesFactoryMethodsForClassesWhoseSchemaTypeDiffersFromTheClassName(): void + { + Head::schema( + Schema::breadcrumbs() + ->item('Home', 'https://example.com') + ->item('Shop', 'https://example.com/shop') + )->schema( + Schema::faq()->question('What is Hypervel Head?', 'A fluent API for managing the document head.') + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('"@type":"BreadcrumbList"', $html); + $this->assertStringContainsString('"itemListElement"', $html); + $this->assertStringContainsString('"item":"https://example.com/shop"', $html); + $this->assertStringContainsString('"@type":"FAQPage"', $html); + $this->assertStringContainsString('"@type":"Question"', $html); + $this->assertStringContainsString('"@type":"Answer"', $html); + } + + public function testSetsBreadcrumbItemsInBulkWithSequentialPositions(): void + { + Head::schema( + Schema::breadcrumbs() + ->item('Home', 'https://example.com') + ->items([ + 'Shop' => 'https://example.com/shop', + 'Shoes' => 'https://example.com/shop/shoes', + ]) + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('"@type":"BreadcrumbList"', $html); + $this->assertStringContainsString('"position":1,"name":"Home"', $html); + $this->assertStringContainsString('"position":2,"name":"Shop"', $html); + $this->assertStringContainsString('"position":3,"name":"Shoes"', $html); + $this->assertStringContainsString('"item":"https://example.com/shop/shoes"', $html); + } + + public function testSetsFaqQuestionsInBulk(): void + { + Head::schema( + Schema::faq()->questions([ + 'What is Hypervel Head?' => 'A fluent API for managing the document head.', + 'Is it free?' => 'Yes, it is open source.', + ]) + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('"@type":"FAQPage"', $html); + $this->assertStringContainsString('"name":"What is Hypervel Head?"', $html); + $this->assertStringContainsString('"name":"Is it free?"', $html); + $this->assertStringContainsString('"text":"Yes, it is open source."', $html); + } + + public function testRegistersCustomSchemaTypesAsFirstClassFactoryMethods(): void + { + Schema::register(JobPosting::class); + + Head::schema( + Schema::jobPosting() + ->title('Senior Hypervel Developer') + ->datePosted('2026-05-13') + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('"@type":"JobPosting"', $html); + $this->assertStringContainsString('"title":"Senior Hypervel Developer"', $html); + $this->assertStringContainsString('"datePosted":"2026-05-13"', $html); + } + + public function testSetsOfferAvailabilityFromSchemaOrgEnumValues(): void + { + Head::schema( + Schema::product() + ->name('Desk') + ->brand(Schema::brand()->name('Acme')) + ->offers( + Schema::offer() + ->price(125) + ->currency('USD') + ->availability(OfferAvailability::InStock) + ) + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('"brand":{"@type":"Brand","name":"Acme"}', $html); + $this->assertStringContainsString('"priceCurrency":"USD"', $html); + $this->assertStringContainsString('"availability":"https://schema.org/InStock"', $html); + } + + public function testIncludesAllSchemaOrgItemAvailabilityValues(): void + { + $this->assertSame('https://schema.org/MadeToOrder', OfferAvailability::MadeToOrder->url()); + $this->assertSame('https://schema.org/Reserved', OfferAvailability::Reserved->url()); + } + + public function testThrowsWhenAMagicSchemaPropertySetterReceivesMultipleArguments(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Magic schema property setters accept a single value, and [' . GenericSchemaObject::class . '] does not define a [name] method.'); + Schema::make('videoObject')->name('Introducing Hypervel Head', 'https://example.com/video'); + } + + public function testMakesSchemaObjectsDirectlyFromClassNamesWithoutRegistration(): void + { + Head::schema( + Schema::make(JobPosting::class) + ->title('Senior Hypervel Developer') + ->datePosted('2026-05-13') + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('"@type":"JobPosting"', $html); + $this->assertStringContainsString('"title":"Senior Hypervel Developer"', $html); + } + + public function testExplicitTypesBypassTheInferredTypeCache(): void + { + $this->assertSame('Article', (new Article)->toArray()['@type']); + $this->assertSame('NewsArticle', (new Article('NewsArticle'))->toArray()['@type']); + $this->assertSame('Article', (new Article)->toArray()['@type']); + } + + #[DataProvider('invalidSchemas')] + public function testRejectsInvalidSchemasOutsideProduction(array $schema, string $message): void + { + Head::schema($schema); + + $this->expectException(InvalidSchema::class); + $this->expectExceptionMessage($message); + Head::toHtml(); + } + + /** + * Supply invalid schema definitions and their diagnostics. + * + * @return iterable, string}> + */ + public static function invalidSchemas(): iterable + { + yield 'context' => [['@context' => 'https://invalid.test', '@type' => 'WebPage'], 'JSON-LD schemas must use the https://schema.org context.']; + yield 'type' => [['@context' => 'https://schema.org'], 'JSON-LD schemas must define an @type.']; + yield 'nested null' => [['@context' => 'https://schema.org', '@type' => 'WebPage', 'author' => ['name' => null]], 'JSON-LD schemas may not contain null or empty string values.']; + yield 'empty string' => [['@context' => 'https://schema.org', '@type' => 'WebPage', 'name' => ''], 'JSON-LD schemas may not contain null or empty string values.']; + } + + public function testLogsInvalidSchemasAndContinuesRenderingInProduction(): void + { + $this->app->detectEnvironment(fn (): string => 'production'); + $schema = ['@context' => 'https://schema.org', '@type' => 'WebPage', 'name' => '']; + $logger = m::mock(LoggerInterface::class); + $logger->shouldReceive('warning')->once()->with( + 'JSON-LD schemas may not contain null or empty string values.', + ['schema' => $schema], + ); + $this->app->instance(LoggerInterface::class, $logger); + + Head::schema($schema); + + $this->assertStringContainsString('