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/docs/plans/2026-10-21-head-port.md b/docs/plans/2026-10-21-head-port.md new file mode 100644 index 0000000000..704ddb065d --- /dev/null +++ b/docs/plans/2026-10-21-head-port.md @@ -0,0 +1,394 @@ +# Head package port + +## Status and scope + +The port, initial PR corrections, and upstream additions are implemented, validated, and reviewed in `/home/binaryfire/workspace/contrib/hypervel/components-head`, branch `feature/head`, based on `0.4`. Next: commit the additions, push to both remotes, reply to bot comments on both PRs, complete the remaining CI/review round, merge the main PR and clean up the worktree when safe. + +Port Laravel Head as `hypervel/head`, preserving its fluent metadata, route macros, Blade rendering, structured data, and optional Inertia integration. Support concurrent tenants, runtime tenant defaults and error pages, canonical tenant origins, and copied child coroutines. Keep the package independent of private tenancy code. Head does not query databases or own a persistent tenant cache; settings retrieval and caching remain application responsibilities. + +After every compaction, read this plan, monorepo root `CLAUDE.md`, components `AGENTS.md`, and relevant READMEs in full. Apply the source/test port workflow one file at a time. Flexibility is useful when it serves these requirements; avoid new registries, parsers, caching layers, and compatibility machinery without a concrete need. + +## References and verified boundaries + +Paths below are relative to `/home/binaryfire/workspace` unless stated otherwise: + +| Reference | Implementation relevance | +| --- | --- | +| `references/laravel/head/src`, `tests`, `_ide_helper.php`, `composer.json` | Authoritative upstream API, named parameters, protected extension methods, source order, and tests. | +| `references/laravel/docs/head.md` | Full feature documentation, precedence, supported SSR setup, and client migration requirements. | +| `references/inertia/packages/core/src/head.ts` and React `App.ts`, `Head.ts`, `createInertiaApp.ts` | `serverHead`, title handling, stable ownership keys, hydration, navigation, and SSR protocol. Relevant files in the checked-out 3.9.1 source match the locally installed 3.7.1 files. | +| `references/laravel/inertia-laravel` | Adapter behavior and capability interfaces. | +| Worktree `src/inertia/src/{InertiaState,Response,ResponseFactory,InertiaServiceProvider}.php`, `Ssr/`, `Commands/CheckSsr.php` | Coroutine-local page/SSR state, immutable SSR result, request configurator capability, and health diagnostics. | +| Worktree `src/container/src/{Container,ContainerResolutionState}.php`, `src/context/src/{CoroutineContext,ReplicableContext,RequestContext}.php` | Unbound auto-singletons, scoped instances, and context replication. | +| Worktree `src/routing/src/ResolvesRouteDependencies.php` | Normal controller dependency injection calls `make()`; mutable builders need intrinsic fresh lifetimes. | +| Worktree `src/database/extension.neon`, `src/database/src/PHPStan/`, `types/Routing/Route.php` | Optional package analysis support and type-fixture conventions. | +| Worktree `src/cache/{composer.json,LICENSE.md,README.md}`, `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` | First-party package skeleton and optional static cleanup. | + +Root Composer/npm metadata is development-only. Installed applications use individual subtree splits; optional Inertia classes and provider discovery cannot be inferred from the components root manifest. + +## Compatibility decisions + +Keep existing Laravel public/protected method names, parameter names, ordering, and extension dispatch, except approved framework type adaptations. No deprecated Head API or legacy-version shim has been identified for removal. Preserve eager `defaults()`, `errors()`, and `inertiaGlobals()`; add runtime resolvers instead of changing their semantics. + +These correctness fixes deliberately change two narrow upstream extension contracts: + +1. **SSR health capability:** change `UniqueHeadGateway::isHealthy()` when the wrapped gateway lacks `HasHealthCheck` from `false` to `LogicException('The configured SSR gateway does not support health checks.')`. Upstream explicitly tests `false`. The wrapper advertises the interface, so otherwise `inertia:check-ssr` falsely reports an unavailable server. Keep supported gateway health results unchanged. No capability registry, alternate decorator classes, or broad exception catch in the command. +2. **Open Graph protected state:** replace the single protected `$defaults` flag with independent media provenance flags. One flag cannot describe a page image together with inherited default video/audio. Public constructors and methods stay unchanged; subclasses directly accessing that protected property need adaptation. Preserve `overlayMedia()` and `hasMedia()` as useful extension methods rather than retaining dead compatibility state. + +Observable correctness fixes also belong in the owner summary: canonical query/fragment preservation, Open Graph media precedence, literal `"0"` values, manifest replacement, title/meta ownership separation, and direct builders honoring registered schema factories. These are bug fixes, not README differences. Generated content hashes change to `xxh128`, but remain deterministic; Inertia treats their values as opaque identifiers. + +Keep Hypervel's `Ssr\Response::$head` and `$body` readonly. Return a new response when stripping duplicate Head tags. This is an internal immutable rendering result, not a read-only application: forms, mutations, database writes, and normal Inertia requests remain available. No JavaScript package fork or wire-protocol change is needed. + +The owner also approved accepting `Paginator|CursorPaginator` in `paginate()` and `fromPaginator()`, and replacing pagination links as one complete relation set. Both paginator contracts expose the same previous/next URL methods. Replacement removes stale relations when the current page has no previous or next page; custom manual definitions must supply their complete set rather than relying on per-relation inheritance. Explain replacement briefly in code and document these behaviors in the Head guide; correctness fixes and additive support do not belong in the README differences or porting guide. + +## Package and provider wiring + +Create `src/head/` using the cache package's metadata/license conventions: PHP `^8.4`, Hypervel `^0.4`, `Hypervel\\Head\\` mapped to `src/`, provider and `Head` alias under `extra.hypervel`. Preserve upstream copyright. README: package name, canonical documentation link, any approved lasting public differences, upstream link. Do not duplicate the feature guide. + +Declare actual runtime dependencies from final imports: `hypervel/collections`, `conditionable`, `container`, `context`, `contracts`, `foundation`, `http`, `macroable`, `routing`, `support`, `view`; directly used PSR Log and Symfony HTTP Foundation/Kernel packages use the repository's compatible constraints. `Arr` belongs to collections even though its namespace is `Hypervel\Support`. Foundation is required for the compiled directive's `app()` call and performance links' `asset()` calls; it is not merely a PHPDoc dependency. Add any further real dependency revealed by final imports. `hypervel/inertia` is optional and may be suggested for the documented integration. No direct Livewire, Pest, database, or frontend dependency is required by Head itself. PHPStan is optional development tooling, not a runtime requirement. + +Root development `composer.json`: autoload Head, add `hypervel/head` to `replace`, provider and `Head` alias to discovery metadata. Do not add Head to `DefaultProviders`. Refresh autoload metadata through Composer; use package-manager commands for any actual root dependency addition. Check installed-package manifests independently. + +`HeadServiceProvider::register()`: + +```php +$this->app->scoped(CurrentHead::class); +$this->app->singleton('head.schema', fn () => new SchemaFactory); +$this->app->alias('head.schema', SchemaFactory::class); +$this->app->singleton('head', fn ($app) => new HeadManager( + $app, + $app->make(HeadRenderer::class), + $app->make(TagRegistry::class), +)); +$this->app->alias('head', HeadManager::class); +``` + +The remaining worker-safe concrete services use auto-singletoning. Facades resolve the canonical string keys. Bind no duplicate concrete singleton aliases. In `boot()`, register Blade and route macros, install exception status capture with `callAfterResolving()` so an already-resolved handler is covered, then register optional Inertia integration after providers have been registered. + +Integration checks `getProviders(InertiaServiceProvider::class) !== []`, which recognizes registered subclasses as well as the base provider, not mere class availability. Preserve the middleware's early-kernel handling. `Integration` is not a service provider: keep its existing `afterResolving` plus `resolved()` immediate-call pattern, rather than calling the protected ServiceProvider helper from it. Type its callback argument as the Kernel contract and call `prependMiddleware()` directly. Register the lazy Head prop before global middleware can throw, so Inertia error responses also receive it. `Container::extend()` also decorates an already-resolved gateway. No container/provider availability work belongs on the request hot path. + +### Exception response status + +Keep Head's early `renderable()` callback for ordinary Blade error views, which render before the final response exists. Also capture the actual response status before application response customization: exception `render()` methods, `Responsable` exceptions and earlier render callbacks can bypass Head's early callback, while `HttpResponseException`, authentication and validation responses can have a different status from its inferred 500. + +Add `Foundation\Exceptions\Handler::beforeResponding(callable $callback): static`, with boot-only registration and a worker-held list of closures. Invoke them with `(Response, Throwable, Request)` after request-local `afterResponse()` callbacks and immediately before the single `respondUsing()` callback. Ignore their return values; response replacement remains with the existing APIs. Use a plain loop, without middleware, events or exception-type guesses. Add the matching `Configuration\Exceptions` delegate and facade annotation; leave the ExceptionHandler contract and forwarding fake unchanged. + +Inside Head's existing handler-resolution callback, register this hook using the already-resolved manager and set its coroutine-local status from the response. Type the callback argument as the ExceptionHandler contract and check each optional hook with `is_callable` before registering it, preserving forwarding fakes and custom handlers without those methods. This ensures Inertia's `handleExceptionsUsing()` and application `respondUsing()` callbacks render metadata for the actual response status with the framework handler. Handler remains independent of Head. Document the additive hook in the errors guide. Code that renders Head inside a custom exception renderer before returning its response still supplies the intended status explicitly. + +The Head regression throws its exceptions from an aliased route middleware closure: route actions deliberately unwrap `HttpResponseException` without invoking Handler. Preserve that routing behavior. Fix `TestResponse::decodeResponseJson()` to read its optional stored exception with null coalescing, so invalid JSON in a plain Symfony response produces the intended assertion instead of an undefined-property error. Add one exact-message regression alongside its existing stored-exception identity test. + +## State ownership and replication + +| Object/state | Lifetime | +| --- | --- | +| HeadManager configuration/resolvers, TagRegistry, SchemaFactory, SchemaValidator, HeadRenderer | Worker-shared; no captured request or tenant. | +| CurrentHead data and status | Coroutine-scoped binding; implements `ReplicableContext`. | +| HeadData, HeadBuilder, TagBuilder hierarchy, SchemaObject hierarchy, ResolvedHead | Implement `Transient`; caller-owned mutable state must not be auto-singletoned. | +| TagRenderer | Immutable configuration; worker sharing is safe. | +| ErrorPages | Manager-owned boot configuration, constructed with its definition closure; no transient marker needed. | +| Middleware, gateway decorator, macro/integration registrars | Worker-safe; retain only services/configuration, never request results. | + +A container-resolution probe confirmed the actual failure: resolving the unmarked builder/schema twice returns the first mutable instance. Test normal container resolution of a builder and schema, not every subclass separately. Use the existing `Hypervel\Contracts\Container\Transient` marker. + +Keep HeadManager's `request(): ?Request` signature and return `$this->app->make('request')`. Hypervel's binding returns the coroutine-local request or creates a request from `app.url` outside HTTP, preserving Laravel-style console/queue canonical rendering. Its guaranteed Request result makes upstream's `bound()` and `instanceof` guards redundant. Do not retain requests in the worker-shared manager. Resolve `CurrentHead` when needed rather than injecting a scoped object into the manager. Inertia detection reads its existing context state directly, as specified below. + +Replication is explicit, not part of every layer merge: + +```php +// HeadData: clone builders only when an independent state copy is requested. +public function replicate(): static +{ + $replica = clone $this; + foreach ($this->builders as $class => $builder) { + $replica->builders[$class] = clone $builder; + } + return $replica; +} + +// CurrentHead: copy data and retain the current status. +public function replicate(): static +{ + $replica = clone $this; + $replica->data = $this->data->replicate(); + return $replica; +} +``` + +Do not add a deep `HeadData::__clone()`: `merge()` and `asDefaults()` already clone layers repeatedly. Add `Schemas::__clone()` to clone each stored SchemaObject and rebuild its identity key with the cloned object's `spl_object_hash()`. Keep content keys for array schemas. Stale object keys can collide with reused identities after original objects die. + +Built-in schema setters normalize nested SchemaObject/DateTime values into arrays/scalars; do not add a generic recursive SchemaObject clone engine. Custom builders or schema subclasses holding additional mutable objects own their `__clone()` behavior. Raw foreign objects supplied as application data retain normal PHP object semantics. + +`flush()` clears runtime tags and status for the current coroutine. Fresh requests are naturally isolated. For sequential independent pages or tenant switches within one coroutine, the application calls `flush()` between pages; do not silently flush during arbitrary tenancy transitions or invent Head-specific tenant partitions. + +## Runtime extension points + +Add these replaceable nullable closure slots to HeadManager, with facade `@method` entries: + +```php +public function resolveDefaultsUsing(?Closure $resolver): static; +public function resolveErrorsUsing(?Closure $resolver): static; +public function resolveCanonicalOriginUsing(?Closure $resolver): static; +``` + +Callback contracts: defaults `(HeadBuilder $head): mixed`; errors `(HeadBuilder $head, int $status): mixed`; canonical origin `(): ?string`. Return values from builder callbacks are ignored, like existing `defaults()`. Registration returns the manager and null removes the resolver. No callback stack or per-tenant resolver registry. + +Use fresh HeadData and the existing `define()` path on every resolution. Precedence: + +1. Eager defaults. +2. Runtime defaults, marked `asDefaults()` to preserve fallback media and title suffix semantics. +3. Route groups and route layers, in their existing sequence. +4. Current runtime page data. +5. Static error defaults/status data. +6. Runtime error data, when the effective status is non-null. + +The explicit render status takes precedence over CurrentHead status. Do not impose a new `>= 400` restriction; upstream supports explicit registered statuses. Run the error resolver even when no static error definition matches. Do not memoize resolved defaults/errors: repeated renders and same-coroutine context changes must observe current data. + +Document boot-only registration for these hooks and existing shared mutators (`defaults`, `errors`, `inertiaGlobals`, `inertia`, `extend`, schema registration, and the matching registry/error-definition methods). Capture worker-safe services in provider boot, then read tenant/request data inside callbacks. Never capture the currently active tenant during boot or mutate global config per request. + +Use generic teams and their settings in public examples. Do not reference private packages, application code, or product plans. Example, using an application-owned worker-safe context service whose `settings()` reads already-loaded team settings: + +```php +public function boot(TeamContext $teams): void +{ + Head::resolveDefaultsUsing(function (HeadBuilder $head) use ($teams): void { + $settings = $teams->settings(); + $head->title($settings->name, suffix: ' - '.$settings->name) + ->description($settings->description) + ->canonical() + ->manifest($settings->manifestUrl); + }); + + Head::resolveErrorsUsing(function (HeadBuilder $head, int $status) use ($teams): void { + $head->title($teams->errorTitle($status)); + }); + + Head::resolveCanonicalOriginUsing(fn (): ?string => $teams->canonicalOrigin()); +} +``` + +Callbacks can execute multiple times per response (Inertia sharing plus Blade rendering, or repeated explicit calls). Reuse loaded settings; Head adds no database/network round trips. Application logic can use all existing fluent metadata and schema APIs in these callbacks. + +Keep `inertiaGlobals()` static, server-owned document tags. Tenant branding, icons, manifests, and any changeable metadata belong in page-managed defaults/runtime data. A runtime globals hook would still leave stale client tags after navigation and is not the correct solution. + +## Canonical URLs + +Add `Canonical::resolveOriginUsing(?Closure $resolver): void`, with static nullable callback and `flushState()`. HeadManager delegates its canonical registrar here, so direct Canonical rendering and manager rendering share one policy. + +- Origin contract: `scheme://host[:port]`, without a path; null preserves normal request-derived behavior. Match UrlGenerator's no-argument callback style. +- Apply only to automatic and relative canonical URLs. Explicit absolute HTTP(S) URLs, recognized case-insensitively, and protocol-relative URLs keep their own host. For protocol-relative URLs, use HTTPS when forced, otherwise the request scheme when available; retain the protocol-relative form when rendered directly without a request and HTTPS is not forced. Existing normalization options still apply. +- Automatic canonical path preserves the request's base URL and path, omits query as upstream request `url()` does, and uses the runtime origin when available. Outside HTTP, HeadManager uses the `app.url` request; the resolver replaces only its origin. Console/queue callers supply the intended page path when rendering a specific page. Direct `Canonical::render(null)` preserves upstream behavior: automatic mode emits no canonical, even with an origin resolver, because no page path is known. Explicit relative paths can use the resolver origin. +- Preserve constructor, `render(?Request)`, and `normalizeUrl(string, ?Request, bool, bool)` signatures and override dispatch. +- Normalize only the URL path. Detach query/fragment without re-encoding them, normalize the path/root slash, then append the untouched suffix. `https://tenant.test/?page=2` must not become `https://tenant.test/?page=2/`. Preserve ports, encoded paths, and fragments. Do not build a general URL validator/parser abstraction. + +## Rendering correctness and bounded optimization + +Port Laravel Head PR #14 at `f95fbedff21ec78f586c198baf44be3b6b3c76af`: an existing Open Graph builder falls back to the resolved canonical URL when its URL is unset. Preserve explicit URLs and the requirement to opt into Open Graph. Follow the PR's optional `$url` parameters and render/array paths, retaining null/empty-string checks. Port all six upstream tests; existing canonical-origin coverage already verifies the underlying resolver. Document the output behavior in the Head guide and a concise README difference; keep the upstream PR reference in this plan, not the README. No porting-guide entry or new caching mechanism. + +Port the exact split-defaults regression from PR #16 at `31874035fe56b2ed5285bfe4b72d3046d5ed773b`: separate site-name and default-image registrations followed by a page image, without a runtime resolver. Preserve the existing per-media provenance implementation. PR #17's Octane container switching and #18's lazy replacements for eager configuration do not apply; retain coroutine-local resolution and the explicit runtime resolvers. + +Port PR #15 at `e5573433e79298403844b2e65469e5713b9fda08` with its existing typed-helper conventions: Event location/image/performer/status/attendance-mode methods, Place, and EventStatus/EventAttendanceMode enums. Preserve its parameter names, enum cases and values, enum-only setters, concrete performer union, and string-location conversion into Place data. These follow Offer, Article and Product; raw schema values remain supported through `SchemaObject::set()`. Add Place to SchemaFactory and its factory annotations, regenerate the Schema facade, and port every EventSchemaTest case using cp-then-modify. Document the helpers, enum use, string-location meaning and `set()` for raw values in the Head guide, with a structured address example. No extra schema classes, normalization layer, validation engine or special typing convention. These additions require no new worker state or database work. + +| Change | Implementation and reason | +| --- | --- | +| Open Graph fallback media | Track default provenance independently for image/video/audio. Higher-layer media replaces only default media of its own kind. If the overlay has no media of that kind, inherit base provenance; otherwise use overlay provenance. Preserve provenance when two defaults layers merge. Keep the existing `overlayMedia` signature and replacement policy; use `hasMedia()` in `isEmpty()` so the protected hook remains meaningful. | +| Literal `"0"` metadata | Title/Description emission and OG/Twitter fallback checks must distinguish null/empty strings from `"0"`. Their `headArray()` filters must also preserve `"0"` instead of dropping it through truthiness. Keep existing empty-string omission where currently intentional. Test both rendered tags and arrays. | +| Manifest override | `GenericLinks::link()` keys `rel=manifest` as one logical link, so a tenant/page URL replaces the inherited manifest. Render its Inertia identity consistently as a singleton. Keep multiple icon candidates and other repeatable links; no broad icon key rewrite or removal/tombstone API. | +| Title ownership | In `uniqueInertiaElements()`, treat title as its own bucket and other tags by `data-inertia` key, matching Inertia core `collect()`. A title and `` both survive. Keep existing semantic ownership keys. | +| Schema factory consistency | `HeadBuilder::schemaFactory()` uses `Container::getInstance()->make(SchemaFactory::class)` when no factory was supplied. Honor explicitly injected factories. Direct/DI-created builders then see registered custom types and reuse the worker factory. Container auto-creation permits standalone use. This is a bug fix; no user-doc escape-hatch section. | +| Schema values | Magic setters use the sole supplied value regardless of its argument name, preserving explicit null; only an omitted argument means true. Infer types from the unchanged concrete class short name, without inheriting parent attributes. Bulk FAQ/breadcrumb helpers cast numeric array keys back to strings. The existing validator descends into nested SchemaObject values through `toArray()`; raw schema arrays otherwise retain their serialization and reference semantics. | +| Pagination | Replace the complete relation set on overlay, including an empty set. Accept ordinary and cursor paginator contracts. Advertise no pagination route attribute because pagination is supplied through the runtime API. | +| Feed route values | Accept bare URLs and lists of URLs as untitled RSS feeds. Keep URL-to-title maps and structured definitions; omitted or null structured titles mean untitled, while other supplied titles must be strings. Omit empty title attributes when rendering. | +| Meta ownership | Preserve semantic keys and aliases for the normal inferred name/property attribute without media. Hash the full stored identity when the attribute is explicitly different or media is present, preventing distinct tags from colliding while retaining overrides of dedicated builders. | +| Registry/input consistency | Make repeated registration of the same custom builder a no-op before checking other builders' route keys. Resource-hint scalar list entries require integer keys; reject ambiguous URL-to-string maps through the existing invalid-value path. Preserve other builders' established filtering of unusable list entries. | +| Route attribute lookup | Memoize TagRegistry's route-attribute map on the instance, invalidate after successful `extend()`. Its key set is bounded by registered builder classes. No static cleanup for this instance-only cache. | +| Schema reflection | Cache inferred SchemaType/class-derived type by concrete class, preserving explicit constructor types and protected `typeFromAttribute()` dispatch. Bounded by loaded schema classes; add `flushState()`. Do not cache tenant values or SchemaObject instances. | +| Content hashes | Replace md5 in TagRenderer, MetaTags and array-schema keys with deterministic `xxh128`; preserve semantic keys. No random worker seed: keys must match across requests/workers and SSR/client handoff. These hashes are identifiers, not trust boundaries. | +| JSON-LD rendering | Encode once in `TagRenderer::jsonLd()` with existing safe JSON flags, derive any default key from that same encoded string, then emit it. `Schemas::toTags()` passes the schema without pre-encoding for a redundant explicit key. Keep explicit supplied keys, escaping, invalid-UTF-8 failures, and schema validation behavior. Insertion-time array content hashing remains separate. | + +Do not cache combined route HeadData: sequential overlay order matters, especially for custom builders whose merge operations are not necessarily associative. Keep ResolvedHead local to each render; its simple lookups do not need another cache. No output cache, tenant-key cache, dynamic worker cache, database schema, or benchmark claims. + +## Inertia integration + +Keep ShareHead's ordinary lazy shared closure, custom prop name, partial-reload omission, and per-request registration. Do not replace it with `always()` or eagerly render before route/controller metadata is set. Detection must tolerate an absent optional package and absent state without creating either: + +```php +return class_exists(InertiaState::class) + && (CoroutineContext::get(InertiaState::CONTEXT_KEY)->page ?? []) !== []; +``` + +`UniqueHeadGateway::dispatch()` returns null unchanged; otherwise: + +```php +return new Response( + $this->renderer->withoutInertiaElements($response->head, $elements), + $response->body, +); +``` + +Preserve exact-string removal of page-managed elements and non-overlapping JavaScript SSR tags, for either ordering of `@head` and ``. Upstream's supported setup removes JS title callbacks and overlapping client `` tags. Do not add an HTML parser or protocol workaround for conflicting configuration. + +The decorator implements and forwards Hypervel's `ConfiguresSsrRequests::configureRequestUsing(?Closure $callback = null): void`, including reset with null. Throw the same clear unsupported-capability LogicException used by ResponseFactory if the underlying gateway lacks it. Preserve disable/path exclusions/health forwarding, subject to the owner-approved health diagnostic change above. No production changes to the Inertia adapter are currently required. + +Document `serverHead: true` (or matching custom prop string) at every `createInertiaApp()`, including a separate SSR entry if present; minimum Inertia client is 3.5. Move overlapping JS Head tags/static branding into Head and remove JS title callbacks. Use self-closing `` to avoid a competing fallback title. Partial reloads retain the previous full page's head by design; tenant/page navigation needing fresh head uses a full visit. + +## Routing editor and PHPStan support + +Port `_ide_helper.php` for editor discovery without runtime autoloading. Exclude it specifically from root source analysis to avoid duplicate routing class declarations; do not globally ignore Head errors. + +Ship `src/head/extension.neon` via package `extra.phpstan.includes`, following database. Register one `MethodsClassReflectionExtension` and one signature interface under `src/head/src/PHPStan/` (for example `WithHeadExtension` and `RoutingMacros`). The interface declares the full upstream named-argument signature once with precise PHPDocs. The extension recognizes `withHead` on Router, RouteRegistrar, Route, PendingResourceRegistration, PendingSingletonResourceRegistration, and the Route facade, including subclasses; preserve existing native methods and exact macro-name matching. + +Use ReflectionProvider to obtain the interface method and a package-owned `WithHeadMethodReflection implements MethodReflection`, following Database's `ForwardedFluentMethodReflection`. Forward signature metadata and construct public `FunctionVariant` instances with the native parameters, template maps, variadic flag and call-site variance, replacing only the return type. Pass the declaring class, return type and static flag from the extension. Avoid PHPStan's internal annotation reflection constructors. Router/facade return RouteRegistrar; the other four return the receiving routing type. Preserve subclass fluent types where runtime returns `$this`; Router still creates a base RouteRegistrar. Do not execute provider boot or runtime macros during analysis. + +Planning verification: class-level stub `@method` declarations recognize `withHead` but replace existing routing class PHPDocs, breaking existing `Route::get()` and `Router::middleware()` analysis. Stub native declarations do not add absent methods. A small extension using a native signature interface passed strict analysis for all six surfaces plus existing methods, and correctly rejected unknown `$heading` and integer `$title`. Use the extension, not a stub or copied routing PHPDoc inventories. + +Add the extension to root source/types configurations and its PHPStan directory to the existing type-fixture scan directories. `types/Head/Route.php` covers all chain positions, return types, valid named values/extensions, and existing routing methods. Use identifier-scoped expected errors with unmatched-ignore reporting in the types fixture for intentionally invalid names/types, following the permitted local suppression rule; no source weakening or global ignore. + +Keep five runtime macro signatures, editor signatures, and analysis signature synchronized. Replace `HeadRouteMacros::bound()`'s runtime `assert()` type narrowing with its accurate generic PHPDoc/local `@var`; retain its public signature. Do not invent runtime validation for a macro closure that the framework binds itself. + +Generate both facade docblocks through the repository's Facade Documenter. Put the built-in schema factory instance `@method` declarations on SchemaFactory so direct factory calls and generated facade methods share one source. Preserve `@mixin HeadManager` and add `@mixin SchemaFactory` to the respective facades. Use the existing `ignoredFacadeDocumenterMethods()` hook for Head's `defaults`, `errors`, `inertiaGlobals`, the three runtime resolvers, `schema`, `extend`, `when`, and `unless`, and Schema's `make` and `register`: their mixins retain callback, class-string and generic types that generated tags flatten. Explain that reason briefly on each hook; do not change the generator. + +Add `types/Head/Facades.php` with one assertion per mechanism: a generated built-in facade method, a direct schema factory call within `Head::schema()`, generic `Schema::make()`, inferred defaults-callback parameter, fluent `Head::when()`, and an expected invalid callback. Keep this distinct from routing macro fixtures. + +## Documentation and cleanup + +Copy upstream `references/laravel/docs/head.md` to `src/docs/head.md`, then adapt namespace, installation and framework terminology. Preserve feature coverage, examples and links; remove unsupported Livewire documentation/fixtures rather than claiming an integration Hypervel does not provide. Add Head to `src/docs/documentation.md` navigation and link from Inertia docs where it helps discovery. + +Add concise runtime-resolver, precedence, canonical-origin and coroutine-use sections, the callback cost guidance, and one note that custom mutable builder properties need clone handling when contexts are copied. Document canonical-origin registration only through `Head::resolveCanonicalOriginUsing()`, alongside the defaults/errors resolvers; `Canonical::resolveOriginUsing()` owns the implementation behind that delegate, not a second documented setup path. Explain that PHPStan users without extension auto-discovery include `vendor/hypervel/head/extension.neon`. Add `event` to the built-in schema factory list (the implementation already registers it). Inertia guidance follows the supported setup above. Keep examples package-agnostic; consuming applications need no changes as part of this port. + +Update `src/docs/porting-from-laravel.md` only for usage requiring actual adaptation: configure worker-shared Head defaults at boot and use runtime resolvers for request-specific defaults; link to feature docs. Do not catalogue bug fixes, hashes, readonly response allocation, or internal caches there. README differences only for approved lasting public contracts, not ordinary correctness fixes. + +Add an optional Head group to `AfterEachTestSubscriber`, using `callIfExists()` for Canonical and SchemaObject `flushState()`, and HeadManager `flushMacros()`. Existing routing cleanup owns routing macro resets. Leave RouteAttributeParser's monotonically increasing metadata-layer counter alone: only unique keys matter, tests compare layer values, and it retains no growing collection. Container teardown clears manager/registry instances; do not duplicate instance cleanup or add manual per-test static resets. + +Remove stale upstream framework references, dead code introduced by fixes, inaccurate comments, unused imports/dependencies, and discarded alternatives. Preserve load-bearing upstream comments and every applicable upstream test. No Boost resources or new compatibility shims. + +## File execution checklist + +During implementation, track remaining files individually and remove entries as completed. Paths below mirror upstream under `src/head/src/`: + +```text +Concerns/BuildsHead.php +CurrentHead.php +Enums/ImageType.php +Enums/Media.php +Enums/OfferAvailability.php +Enums/OgType.php +Enums/RobotsRule.php +Enums/TwitterCard.php +ErrorPages.php +Exceptions/InvalidSchema.php +Facades/Head.php +Facades/Schema.php +HeadBuilder.php +HeadData.php +HeadManager.php +HeadServiceProvider.php +Inertia/Integration.php +Inertia/ShareHead.php +Inertia/UniqueHeadGateway.php +Rendering/HeadRenderer.php +Rendering/ResolvedHead.php +Rendering/TagRenderer.php +Routing/HeadRouteMacros.php +Routing/RouteAttributeParser.php +Schema/Article.php +Schema/BlogPosting.php +Schema/Brand.php +Schema/Breadcrumbs.php +Schema/Event.php +Schema/Faq.php +Schema/GenericSchemaObject.php +Schema/Offer.php +Schema/Organization.php +Schema/Person.php +Schema/Product.php +Schema/SchemaFactory.php +Schema/SchemaObject.php +Schema/SchemaValidator.php +Schema/WebPage.php +Schema/WebSite.php +SchemaType.php +TagRegistry.php +Tags/AlternateLinks.php +Tags/Canonical.php +Tags/Description.php +Tags/FeedLinks.php +Tags/GenericLinks.php +Tags/GroupedTagBuilder.php +Tags/MetaTags.php +Tags/OpenGraph.php +Tags/PaginationLinks.php +Tags/PerformanceLinks.php +Tags/Robots.php +Tags/Schemas.php +Tags/StringTagBuilder.php +Tags/TagBuilder.php +Tags/Title.php +Tags/Twitter.php +``` + +Additional new files: package `composer.json`, `LICENSE.md`, `README.md`, `_ide_helper.php`, `extension.neon`, `src/PHPStan/{RoutingMacros,WithHeadExtension,WithHeadMethodReflection}.php`; documentation page and routing/facade type fixtures. SchemaFactory owns its magic-method docblock; both Head facades include the generation hooks described above. Existing-file changes: root Composer metadata, PHPStan source/types configurations, optional testing cleanup, TestResponse, Foundation handler/configuration, Exceptions facade, docs navigation/Inertia/errors/porting guide as above. + +Port tests using `cp` then convert Pest cases to PHPUnit, retaining upstream directories under `tests/Head/`. Shared TestCase is justified by fixture views/provider setup and extends Hypervel Testbench. Configure views in `defineEnvironment()`, use framework container methods and request context, and remove only the Livewire provider/test/layout. Convert Pest setup into the base/individual cases; do not retain `Pest.php` or add Pest dependencies. + +```text +ArchTest.php +Feature/ErrorPagesTest.php +Feature/HeadBuilderTest.php +Feature/HeadDataTest.php +Feature/HeadManagerTest.php +Feature/HeadServiceProviderTest.php +Feature/InertiaCustomPropTest.php +Feature/InertiaTest.php +Feature/Rendering/HeadRendererTest.php +Feature/Rendering/TagRendererTest.php +Feature/Routing/RouteHeadTest.php +Feature/Routing/WithHeadTest.php +Feature/Schema/SchemaTest.php +Feature/Tags/CanonicalTest.php +Feature/Tags/GenericLinksTest.php +Feature/Tags/MetaTagsTest.php +Feature/Tags/OpenGraphTest.php +Feature/Tags/PerformanceLinksTest.php +Feature/Tags/PwaTest.php +Feature/Tags/RobotsTest.php +Feature/Tags/ThemeColorTest.php +Feature/Tags/TitleTest.php +Feature/Tags/TwitterTest.php +Fixtures/ConflictingRouteAttribute.php +Fixtures/JobPosting.php +Fixtures/ReadingTime.php +Fixtures/ResolvesHttpKernelEarly.php +Fixtures/views/app-directives.blade.php +Fixtures/views/app-inertia-head-first.blade.php +Fixtures/views/app-nested.blade.php +Fixtures/views/app.blade.php +Fixtures/views/nested-head.blade.php +Fixtures/views/errors/404.blade.php +TestCase.php +``` + +Retain the upstream architecture assertions through a small PHPUnit/token-based check for strict types, debug calls and direct env calls; no architecture-test framework dependency. Add `CoroutineIsolationTest.php` for Hypervel-specific lifetime/replication tests; place other regressions into the corresponding upstream test file rather than duplicate custom suites. + +## Verification plan + +Run each created/changed test class immediately, then affected package suites; all test/lint/analysis commands sequential, bounded by execution timeouts, from worktree root. No benchmarks without fresh owner confirmation that the machine is idle. + +| Area | Required behavior checks | +| --- | --- | +| Upstream parity | Every applicable upstream case, named arguments, route cache/serialization, extension validation, schema validation/logging, and Blade directive status behavior. | +| Resolution | Eager callbacks remain eager; runtime callbacks are lazy, replaceable/clearable, use fresh data, and obey all six layers. Cover static/runtime error interaction, explicit status and unmapped error status. Repeated resolves after changed context observe new values. | +| Container/coroutines | Two resolutions of builder/schema are independent. Interleaved tenant A/B requests do not mix runtime tags, errors, origin, or branding. Copied parent/child/sibling contexts independently mutate builder/schema data and retain copied status; clone schema identity keys match stored objects. Same-coroutine `flush()` clears page data/status while retaining boot defaults. | +| Canonical | Auto and relative URLs on tenant aliases/custom ports/base paths, absolute external URLs, resolver null/reset, console/queue `app.url` fallback with and without a runtime origin, no automatic canonical for a null request even with a resolver, explicit relative paths with a resolver and no request, HTTPS flag, trailing slash, query and fragment preservation. | +| OG/zero values | Default image/video/audio replacement independently; repeated defaults and runtime defaults retain provenance; ordinary page media still accumulates by URL. `"0"` survives title/description, inferred/explicit OG/Twitter, and array output. | +| Links/schema | One manifest after inheritance/override, multiple valid icon candidates, schema clone isolation, explicit factory precedence and registered factory lookup, class inference cache with explicit type bypass, existing schema validation and JSON escaping/errors. | +| PR regressions | Named/null magic setters, concrete multiword schema names, numeric bulk labels, nested schema-object validation, independent meta attributes and dedicated-builder overrides, complete pagination replacement and cursor input, untitled feeds, idempotent extensions, and invalid scalar resource-hint maps. | +| Inertia protocol | Initial Blade and JSON page output/custom prop; partial reload omission; stable data-inertia keys; title plus meta-title; SSR enabled/disabled/null; both Blade ordering fixtures; preserve non-overlapping JS head and SSR body; wrapped response remains untouched. Forward SSR configurator/reset, disable/exclude/health and unsupported-capability diagnostics. | +| Provider availability | Head without Inertia provider; installed Inertia classes with discovery disabled; enabled integration; handler and kernel resolved before Head boot. Testbench defaults to disabled discovery and supports explicit `getPackageProviders()`; use those boundaries rather than root development discovery assumptions. | +| Exception responses | Foundation: one ordering test verifies the actual response after request-local replacement, argument identity, ignored observer returns, and execution before `respondUsing()`. Head: one data-provider test covers embedded responses, `Responsable`, exception `render()`, and an earlier application render callback through Inertia's exception hook; verify error titles and runtime resolver status. Preserve ordinary Blade/abort coverage. | +| Static analysis/editor | Root source analysis and strict types fixtures; six routing surfaces, chaining/subclasses, named params, pre-existing routing PHPDocs unaffected. Full signature parity against runtime/editor definitions. | + +Use fake SSR gateways and coroutine-local tenant fixtures; no database/network service is necessary for Head tests. Verify emitted strings and ownership behavior against the inspected Inertia JS contract through PHP assertions. No wire-protocol changes or new JavaScript test harness are needed. + +For Head-only corrections, run `composer lint:fix`, `composer analyse`, the Head suite and affected facade checks sequentially. Run broader suites only for changes affecting shared framework behavior or unresolved evidence; do not repeat a full run solely because it exposed a metadata failure. Respect configured external-service availability and report skipped coverage accurately. Investigate failures at their owning boundary; do not weaken tests/types or add broad suppressions. + +Finally perform a fresh complete diff review for API parity, correctness, coroutine ownership, bounded caches, unnecessary work, Laravel ergonomics, and stale material. Request peer code review at the owner-approved implementation completion boundary with actual verification results. Resolve all findings and any new approval items, summarize in chat, and use Codesonic notification only as a ping to check chat. 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/docs/documentation.md b/src/docs/documentation.md index 3550c286ad..d07390cf27 100644 --- a/src/docs/documentation.md +++ b/src/docs/documentation.md @@ -102,6 +102,7 @@ - [API Client](/docs/{{version}}/api-client) - [Fortify](/docs/{{version}}/fortify) - [gRPC](/docs/{{version}}/grpc) + - [Head](/docs/{{version}}/head) - [Horizon](/docs/{{version}}/horizon) - [Inertia](/docs/{{version}}/inertia) - [Nested Set](/docs/{{version}}/nested-set) 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/docs/head.md b/src/docs/head.md new file mode 100644 index 0000000000..20a594e84d --- /dev/null +++ b/src/docs/head.md @@ -0,0 +1,833 @@ +# Head + +- [Introduction](#introduction) +- [Installation](#installation) +- [Quickstart](#quickstart) +- [Resolution Precedence](#resolution-precedence) +- [Defining Metadata](#defining-metadata) + - [Defaults](#defaults) + - [Runtime Defaults](#runtime-defaults) + - [Route Metadata](#route-metadata) + - [Runtime Metadata](#runtime-metadata) + - [Error Pages](#error-pages) +- [Open Graph](#open-graph) + - [X / Twitter Cards](#twitter-cards) +- [Theme Colors](#theme-colors) +- [Application Metadata and Icons](#app-metadata-and-icons) +- [Progressive Web Apps](#progressive-web-apps) +- [Performance and Discovery](#performance-and-discovery) +- [Custom Tags](#custom-tags) +- [Schemas](#schemas) + - [Events](#events) + - [Breadcrumbs](#breadcrumbs) + - [FAQs](#faqs) + - [Custom Schemas](#custom-schemas) +- [Rendering](#rendering) + - [Blade](#blade) + - [Inertia](#inertia) + - [Static Analysis](#static-analysis) + + +## Introduction + +Head provides a fluent API for managing your application's document `` element, including title and meta tags, Open Graph metadata, canonical URLs, robots directives, performance hints, and structured data. It works with Blade and Inertia. + + +## Installation + +You may install Head using the Composer package manager: + +```shell +composer require hypervel/head +``` + + +## Quickstart + +Register site-wide defaults in a service provider: + +```php +use Hypervel\Head\Facades\Head; +use Hypervel\Head\HeadBuilder; + +Head::defaults(fn (HeadBuilder $head) => $head + ->title('Hypervel', suffix: ' - Hypervel') + ->description('Build something great.')); +``` + +Set page-specific metadata at runtime: + +```php +Head::title($post->title) + ->description($post->description); +``` + +Render the resolved tags in your layout: + +```blade + + @head + +``` + + +## Resolution Precedence + +Page metadata resolves in the following order, from lowest to highest priority: + +1. Page defaults registered with `defaults()` +2. Runtime defaults +3. Route group and route metadata, in their declared order +4. Runtime page metadata +5. Registered error metadata +6. Runtime error metadata + +Higher layers replace lower layers field by field. For example, a runtime title replaces the route title without replacing the route description. The sections that follow describe how to set metadata at each layer. For information about rendering the resolved metadata in Blade and Inertia, see [Rendering](#rendering). + + +## Defining Metadata + +Head allows you to define metadata using site-wide defaults, route metadata, runtime calls, and error page definitions. + + +### Defaults + +Register page defaults in a service provider's `boot` method. The callback runs immediately, and its values are shared by every request handled by the worker: + +```php +use Hypervel\Head\Enums\OgType; +use Hypervel\Head\Facades\Head; +use Hypervel\Head\HeadBuilder; + +Head::defaults(function (HeadBuilder $head): void { + $head + ->title('Hypervel', suffix: ' - Hypervel') + ->description('Build something great.') + ->canonical() + ->og(siteName: 'Hypervel', type: OgType::Website) + ->searchableByRobots() + ->preconnect('https://fonts.example.com'); +}); +``` + +Defaults are the lowest-priority page metadata layer. If no route, runtime, or error metadata sets a title, `Hypervel` renders as-is. When a higher layer sets a page title, the inherited suffix is applied, so `Head::title('About')` renders `About - Hypervel`. Pass `exact: true` for titles that should ignore an inherited prefix or suffix. + +Calling `Head::canonical()` renders a canonical URL using the current request URL. To set an explicit URL, pass a string such as `Head::canonical('/about')`. Canonical URLs are normalized to `https` by default; pass `forceHttps: false` to preserve the request scheme. + +Robots directives may be passed as a raw string, as `RobotsRule` enum cases, or as a list mixing both forms. Lists are rendered as comma-separated directives, so `Head::robots([RobotsRule::NoIndex, RobotsRule::NoFollow])` renders `noindex, nofollow`. + +For convenience, the `searchableByRobots` method renders `all`, while the `hiddenFromRobots` method renders `none`. + + +### Runtime Defaults + +When defaults depend on the current request, register a callback with `resolveDefaultsUsing`. For example, an application may choose its title, description, and manifest from the current team's settings: + +```php +use App\Support\TeamContext; +use Hypervel\Head\Facades\Head; +use Hypervel\Head\HeadBuilder; + +public function boot(TeamContext $teams): void +{ + Head::resolveDefaultsUsing(function (HeadBuilder $head) use ($teams): void { + $settings = $teams->settings(); + + $head->title($settings->name, suffix: ' - '.$settings->name) + ->description($settings->description) + ->canonical() + ->manifest($settings->manifestUrl); + }); + + Head::resolveErrorsUsing(function (HeadBuilder $head, int $status) use ($teams): void { + $head->title($teams->errorTitle($status)); + }); + + Head::resolveCanonicalOriginUsing(fn (): ?string => $teams->canonicalOrigin()); +} +``` + +In this example, `TeamContext` is an application service that reads the current team's already-loaded settings from coroutine-local storage. Register the callbacks during boot and read request-specific values inside them. Do not capture a particular team during boot or change global configuration while handling a request. + +Runtime defaults override registered defaults and remain fallbacks for route and page metadata. The error callback runs after registered error metadata whenever a status has been set, including statuses without a registered definition. Passing a status explicitly to `Head::render()` overrides the current status. + +The canonical origin callback returns a value such as `https://team.example.com:8443`, without a path, or `null` to use the request's origin. It applies to automatic and relative canonical URLs; an explicit absolute URL keeps its own origin. Query strings and fragments in explicit URLs are preserved. Outside an HTTP request, automatic canonical URLs use `app.url`; supply the page path explicitly when rendering a particular page from a command or job. + +Each registration replaces the previous callback. Pass `null` to remove it. Callbacks may run more than once per response, such as when Inertia shares metadata and Blade renders the layout. Reuse settings already loaded for the request instead of querying the database each time. + +Page metadata and response status are isolated between concurrent requests. If one coroutine renders several independent pages, call `Head::flush()` between pages to clear its page metadata and status. Registered defaults and callbacks remain available. + +Custom tag builders or schema subclasses that hold mutable objects should implement `__clone()` when those objects need independent copies in child coroutines created with copied context. Built-in builders and schemas handle this automatically. + + +### Route Metadata + +You may define metadata directly on routes, which is especially useful for semi-static pages whose metadata is known ahead of time. + + +#### Routes and Groups + +```php +Route::view('/contact', 'contact') + ->name('contact') + ->withHead( + title: 'Contact Us', + description: 'Get in touch.', + ); +``` + +Shared route metadata may be applied to a group at any position in the chain: + +```php +Route::withHead(robots: 'noindex, nofollow') + ->prefix('admin') + ->name('admin.') + ->group(function () { + Route::get('/dashboard', DashboardController::class) + ->name('dashboard') + ->withHead(title: 'Dashboard'); + }); +``` + +You may also define metadata for resource and singleton routes: + +```php +Route::resource('posts', PostController::class)->withHead( + robots: 'index, follow', +); + +Route::singleton('profile', ProfileController::class)->withHead( + title: 'Your Profile', +); +``` + +The `withHead` method stores plain arrays through Hypervel's native route metadata API. It is equivalent to calling the `metadata` method with the attributes nested under a `head` key, so the metadata remains compatible with cached routes. + +The named arguments are intentionally limited to Head's built-in route properties so editors and static analysis can catch misspelled names. Route attributes registered by custom tag builders may be passed through `extensions`: + +```php +Route::get('/article', ArticleController::class)->withHead( + title: 'Article', + extensions: ['readingTime' => 4], +); +``` + + +#### Supported Properties + +The supported route properties map to the same names as the fluent builder methods: + +| Category | Properties | +| --- | --- | +| Document | `title`, `description`, `canonical`, `robots` | +| Application metadata | `themeColor`, `applicationName`, `colorScheme`, `referrer`, `viewport`, `appleWebAppTitle`, `webAppCapable`, `appleWebAppStatusBarStyle` | +| Social | `og`, `ogImage`, `ogVideo`, `ogAudio`, `twitter`, `twitterImage` | +| Performance | `preload`, `prefetch`, `preconnect`, `dnsPrefetch` | +| Discovery | `alternates`, `feed`, `icon`, `favicon`, `appleTouchIcon`, `appleTouchStartupImage`, `maskIcon`, `manifest` | +| Structured data | `schema` | +| Custom tags | `meta`, `link` | + +Nested option names use the same `camelCase` naming as the fluent API, such as `forceHttps`, `siteName`, and `secureUrl`. + +Repeatable properties, such as `ogImage`, `preload`, `feed`, `schema`, `icon`, and `appleTouchStartupImage`, accept either a single value or a list. + + +### Runtime Metadata + +When a value isn't known until a request arrives, such as the title of a post being viewed, you may set it at runtime: + +```php +use Hypervel\Head\Facades\Head; + +public function __invoke(Post $post): Response +{ + Head::title($post->title); + + // ... +} +``` + +Runtime calls made via the `Head` facade override route metadata for request-dependent data. Controllers and actions are the most common places to make these calls: + +```php +use App\Models\Post; +use Hypervel\Head\Facades\Head; + +public function show(Post $post) +{ + Head::title($post->title) + ->description($post->description); + + return view('posts.show', ['post' => $post]); +} +``` + +Multiple runtime calls are merged in the order they run. For single-value fields such as title, description, canonical URL, and robots directives, the later call takes precedence. Repeatable fields retain multiple entries, but adding the same key again updates the earlier entry. For the `ogImage` method, the URL is the key: + +```php +Head::ogImage('/images/cover.jpg', alt: 'Draft cover') + ->ogImage('/images/gallery.jpg', alt: 'Gallery image') + ->ogImage('/images/cover.jpg', alt: 'Final cover', width: 1200, height: 630); +``` + +```html + + + + + + +``` + +Open Graph media inherited from your defaults acts as a fallback. When route, runtime, or error metadata defines its own media of the same type, the default media is replaced instead of merged, so a page's `og:image` takes precedence over a site-wide default image. + +You may fluently define conditional metadata using the `when` and `unless` methods: + +```php +Head::title($post->title) + ->when($post->isDraft(), fn ($head) => $head->hiddenFromRobots()); +``` + + +### Error Pages + +Typically, you should register error metadata within the `boot` method of your application's `AppServiceProvider` class: + +```php +use Hypervel\Head\ErrorPages; +use Hypervel\Head\Facades\Head; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + Head::errors(function (ErrorPages $errors): void { + $errors->defaults(robots: 'noindex, follow'); + + $errors->status( + 404, + title: 'Page Not Found', + description: 'The page you are looking for could not be found.', + ); + }); +} +``` + +The `defaults` and `status` methods also accept the same fluent builder callback used by `Head::defaults()`: + +```php +use Hypervel\Head\ErrorPages; +use Hypervel\Head\Facades\Head; +use Hypervel\Head\HeadBuilder; + +Head::errors(function (ErrorPages $errors): void { + $errors->status(404, fn (HeadBuilder $head) => $head + ->title('Page Not Found') + ->description('The page you are looking for could not be found.')); +}); +``` + +When a response is rendered for a registered error status, that metadata takes precedence over page metadata. A [runtime error callback](#runtime-defaults) may customize it further. + +With the framework exception handler, Head automatically detects the response status when rendering an error view or executing a response callback such as Inertia's `handleExceptionsUsing()` method. If an `$exceptions->render()` callback or an exception's own `render()` or `toResponse()` method renders Head before returning its response, call `Head::status(404)` first or pass the status to `@head(404)` so the error metadata is applied. + + +## Open Graph + +You may set Open Graph properties using the `og` method. Repeatable media may be added using the top-level methods, which accept named arguments directly: + +```php +use Hypervel\Head\Enums\ImageType; +use Hypervel\Head\Enums\OgType; + +Head::og(type: OgType::Article, title: $post->title) + ->ogImage($post->hero_image_url) + ->ogImage( + $post->gallery_image_url, + alt: $post->gallery_image_alt, + width: 1200, + height: 630, + type: ImageType::Jpeg, + ); +``` + +The `ogImage`, `ogVideo`, and `ogAudio` methods accept a URL as their first argument, along with optional named arguments such as `alt`, `width`, `height`, `type`, and `secureUrl` where supported by the Open Graph specification. + +You may pass image MIME types as `ImageType` enum cases anywhere the API accepts an image `type`, such as `ImageType::Svg`, `ImageType::Png`, `ImageType::Jpeg`, and `ImageType::Webp`. + +> [!NOTE] +> Document `title`, `description`, and canonical URL values automatically fill missing `og:title`, `og:description`, and `og:url` values. + +For a single Open Graph image with no other attributes, you may pass the `image` named argument to the `og` method: + +```php +Head::og( + type: OgType::Website, + title: $page->title, + description: $page->description, + image: $page->og_image_url, +); +``` + +The `og(image: ...)` and `ogImage(...)` calls write to the same underlying image list, so you may use whichever is more expressive at the call site. You may use the [`meta`](#custom-tags) method for custom Open Graph extensions such as product or article properties. + + +### X / Twitter Cards + +To render X / Twitter cards from the same title, description, and image used by Open Graph, register `twitter()` in your defaults: + +```php +use Hypervel\Head\Enums\TwitterCard; +use Hypervel\Head\Facades\Head; +use Hypervel\Head\HeadBuilder; + +Head::defaults(fn (HeadBuilder $head) => $head->twitter( + card: TwitterCard::SummaryWithLargeImage, +)); +``` + +Then set page-level metadata: + +```php +Head::title('Introducing Hypervel Head') + ->description('A fluent API for Hypervel document head metadata.') + ->ogImage('https://example.com/social.jpg', alt: 'Introducing Hypervel Head'); +``` + +This renders matching Twitter tags: + +```html + + + + + +``` + +You may customize individual pages with explicit Twitter values: + +```php +Head::twitter(title: $post->social_title) + ->twitterImage($post->social_image_url, alt: $post->title); +``` + +Route metadata accepts `twitter` and `twitterImage`. + + +## Theme Colors + +You may set theme colors globally, per route, or at runtime: + +```php +Head::themeColor('#0f172a'); +``` + +This renders a `` tag. For media-specific theme colors, you may use the `Media` enum: + +```php +use Hypervel\Head\Enums\Media; + +Head::themeColor('#ffffff', media: Media::Light) + ->themeColor('#111827', media: Media::Dark); +``` + +The `Media` enum also includes `Portrait` and `Landscape`. The `media` argument also accepts a custom media query string. + +Route metadata supports a single theme color through the same `camelCase` key: + +```php +Route::view('/dashboard', 'dashboard')->withHead( + themeColor: '#0f172a', +); +``` + + +## Application Metadata and Icons + +Head includes methods for common browser and application metadata: + +```php +use Hypervel\Head\Enums\ImageType; +use Hypervel\Head\Enums\Media; + +Head::applicationName('Hypervel') + ->colorScheme('light dark') + ->referrer('strict-origin-when-cross-origin') + ->viewport('width=device-width, initial-scale=1') + ->appleWebAppTitle('Hypervel') + ->webAppCapable() + ->appleWebAppStatusBarStyle('black') + ->favicon('/favicon.svg', type: ImageType::Svg) + ->icon('/favicon-32x32.png', type: ImageType::Png, sizes: '32x32') + ->appleTouchIcon('/apple-touch-icon.png', sizes: '180x180') + ->appleTouchStartupImage('/launch.png', media: Media::Portrait) + ->maskIcon('/safari-pinned-tab.svg', color: '#111827') + ->manifest('/site.webmanifest'); +``` + +The `favicon` method is an alias for the `icon` method and accepts the same `type`, `sizes`, and `media` arguments. + +Route metadata uses the same names: + +```php +use Hypervel\Head\Enums\ImageType; +use Hypervel\Head\Enums\Media; + +Route::view('/dashboard', 'dashboard')->withHead( + applicationName: 'Hypervel', + colorScheme: 'light dark', + appleWebAppTitle: 'Hypervel', + webAppCapable: true, + appleWebAppStatusBarStyle: 'black', + favicon: [ + ['href' => '/favicon.svg', 'type' => ImageType::Svg], + ['href' => '/favicon-32x32.png', 'type' => ImageType::Png, 'sizes' => '32x32'], + ], + appleTouchIcon: ['href' => '/apple-touch-icon.png', 'sizes' => '180x180'], + appleTouchStartupImage: ['href' => '/launch.png', 'media' => Media::Portrait], + manifest: '/site.webmanifest', +); +``` + + +## Progressive Web Apps + +The `pwa` method configures the common document `` tags needed for an installable web app: + +```php +Head::pwa( + name: 'Hypervel', + manifest: '/site.webmanifest', + themeColor: '#0f172a', + appleTouchIcon: '/apple-touch-icon.png', + appleWebAppStatusBarStyle: 'black', +); +``` + +This renders the application name, web application manifest link, and iOS standalone metadata. If provided, the theme color, Apple status bar style, and Apple touch icon are also rendered. Creating the web application manifest and registering a service worker remain your application's responsibility. + +You may use the `pwa` method in defaults or runtime metadata. Route metadata supports the individual properties shown above. + + +## Performance and Discovery + +Head renders performance hints, pagination links, locale alternates, and feed discovery: + +```php +Head::preload(asset('fonts/inter.woff2'), as: 'font', crossorigin: true) + ->prefetch(asset('images/next.webp')) + ->preconnect('https://cdn.example.com') + ->dnsPrefetch('https://analytics.example.com') + ->paginate($posts) + ->alternates([ + 'en' => 'https://example.com/en/about', + 'fr' => 'https://example.com/fr/about', + 'x-default' => 'https://example.com/about', + ]) + ->feed('/feed', title: 'Hypervel RSS') + ->feed('/feed.atom', type: 'atom', title: 'Hypervel Atom'); +``` + +The `paginate` method accepts both ordinary and cursor paginators. Each call replaces the complete set of previous and next links, removing links that do not apply to the new page. + +Feed route metadata accepts a URL, a list of URLs, a URL-to-title map, or a structured definition with `href`, optional `title`, and optional `type`. A bare URL produces an untitled RSS feed. When calling `Head::feed()` directly, pass an empty title to omit the title attribute. + +For local assets, `preloadAsset()` and `prefetchAsset()` resolve the URL through the `asset()` helper and detect the `as` attribute from the file extension. Font preloads automatically include `crossorigin`, which the preload specification requires even for same-origin fonts: + +```php +Head::preloadAsset('fonts/inter.woff2') + ->prefetchAsset('images/next.webp'); +``` + +```html + + +``` + +You may pass `as` explicitly to override detection. The `preloadAsset` method will throw an exception when the `as` attribute cannot be detected from the extension because browsers ignore preloads without this attribute; the `prefetchAsset` method will simply omit it. + + +## Custom Tags + +For tags without a dedicated method, use `meta()` and `link()`: + +```php +Head::meta('format-detection', 'telephone=no') + ->meta('article:author', $post->author->name) + ->link('search', '/opensearch.xml', [ + 'type' => 'application/opensearchdescription+xml', + 'title' => 'Hypervel Search', + ]) + ->link('me', 'https://social.example.com/@hypervel'); +``` + +You may include a media query on a meta tag when the browser should only apply the tag under matching conditions: + +```php +use Hypervel\Head\Enums\Media; + +Head::meta('theme-color', '#ffffff', media: Media::Light) + ->meta('theme-color', '#111827', media: Media::Dark); +``` + +The `meta` method uses the `name` attribute for regular meta tags. For keys that typically use the `property` attribute, such as Open Graph (`og:`) or article metadata (`article:`), the method switches automatically: + +```php +Head::meta('description', 'About Hypervel') + ->meta('og:title', 'About Hypervel'); +``` + +```html + + +``` + +You may pass `property: true` or `property: false` to explicitly select either attribute. + + +## Schemas + +Built-in schema builders cover the common JSON-LD types: + +```php +use Hypervel\Head\Enums\OfferAvailability; +use Hypervel\Head\Facades\Schema; + +Head::schema( + Schema::product() + ->name($product->name) + ->offers( + Schema::offer() + ->price($product->price) + ->currency('USD') + ->availability(OfferAvailability::InStock) + ) +); +``` + +The built-in factory methods are `article`, `blogPosting`, `product`, `offer`, `brand`, `breadcrumbs`, `event`, `faq`, `organization`, `person`, `place`, `webPage`, and `webSite`. Unknown factory methods create a generic schema object, so you can still express custom schema.org types. + +When JSON-LD schema data is invalid, Head throws an exception in non-production environments and logs a warning in production. + + +### Events + +The event builder provides methods for dates, locations, images, performers, organizers, and offers. Use the `EventStatus` and `EventAttendanceMode` enums to describe the event's status and how people attend: + +```php +use Hypervel\Head\Enums\EventAttendanceMode; +use Hypervel\Head\Enums\EventStatus; +use Hypervel\Head\Facades\Head; +use Hypervel\Head\Facades\Schema; + +Head::schema( + Schema::event() + ->name('Community Workshop') + ->startDate($event->starts_at) + ->location( + Schema::place()->name('Community Center')->address([ + '@type' => 'PostalAddress', + 'streetAddress' => '123 Main Street', + 'addressLocality' => 'Portland', + 'addressRegion' => 'OR', + 'postalCode' => '97201', + 'addressCountry' => 'US', + ]) + ) + ->image($event->image_url) + ->eventStatus(EventStatus::Scheduled) + ->eventAttendanceMode(EventAttendanceMode::Offline) +); +``` + +Passing a string to `location` creates a `Place` with that name. You may also pass another schema object, such as `Schema::virtualLocation()->url($event->stream_url)`. The `performer` method accepts a person, an organization, or a list of those builders, and `image` accepts a URL or a list of URLs. + +For raw values or shapes outside the typed helpers, every schema builder provides a `set` method. For example, `$eventSchema->set('location', [$place, $virtualLocation])` defines multiple locations, and `$eventSchema->set('location', 'Venue')` preserves a plain text location. + + +### Breadcrumbs + +Breadcrumb items may be added one at a time or in bulk. Positions are assigned automatically in the order the items are added: + +```php +Head::schema( + Schema::breadcrumbs()->items([ + 'Home' => route('home'), + 'Shop' => route('shop.index'), + 'Shoes' => route('shop.category', 'shoes'), + ]) +); +``` + +You may use the `item` method to append a single breadcrumb item: + +```php +Schema::breadcrumbs() + ->item('Home', route('home')) + ->item('Shop', route('shop.index')); +``` + + +### FAQs + +FAQ entries follow the same pattern. You may add them one at a time using the `question` method or in bulk using the `questions` method: + +```php +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.', + ]) +); +``` + + +### Custom Schemas + +You may explicitly register custom schema types during provider boot: + +```php +use DateTimeInterface; +use Hypervel\Head\Facades\Schema; +use Hypervel\Head\Schema\SchemaObject; +use Hypervel\Head\SchemaType; + +#[SchemaType('JobPosting')] +class JobPosting extends SchemaObject +{ + public function title(string $title): static + { + return $this->set('title', $title); + } + + public function datePosted(DateTimeInterface|string $date): static + { + return $this->date('datePosted', $date); + } +} + +Schema::register(JobPosting::class); + +Head::schema( + Schema::jobPosting() + ->title('Senior Hypervel Developer') + ->datePosted(now()) +); +``` + + +## Rendering + +Head resolves page metadata into tags for the current response. How these tags are rendered depends on your application stack. + +The HTML renderer powers the `@head` directive and the rendered elements that Head shares with Inertia via the `head` prop. The array renderer powers `Head::toArray()` for applications that need the resolved metadata as structured data. + + +### Blade + +Render the accumulated tags in your layout's `` with the `@head` directive: + +```blade + + + @head + +``` + +The `@head` directive renders synchronously, so you should define page metadata before the layout is rendered. + + +### Inertia + +Use the same `@head` directive in your Inertia root template, alongside Inertia's own components: + +```blade + + + + @head + + @viteReactRefresh + @vite(['resources/css/app.css', 'resources/js/app.tsx']) + + + + + + +``` + +When the [Inertia adapter](/docs/{{version}}/inertia) is installed and its service provider is registered, Head automatically shares the page-managed head as an array of rendered element strings under a `head` prop on every page object: + +```json +{ + "props": { + "head": [ + "Dashboard - Hypervel", + "" + ] + } +} +``` + +Enable Inertia's `serverHead` option wherever your application calls `createInertiaApp()`. The option is available in Inertia 3.5 and later: + +```js +createInertiaApp({ + // ... + serverHead: true, +}); +``` + +Each page-managed element has a stable `data-inertia` key. The `@head` directive renders the initial document, after which Inertia adopts those elements and keeps them synchronized during standard visits, [instant visits](https://inertiajs.com/docs/v3/the-basics/instant-visits), and back and forward navigation. The elements are present in the initial HTML response, so crawlers and link-preview bots can read them without executing JavaScript. No client-side `` component is required. + +This works with or without [server-side rendering (SSR)](https://inertiajs.com/docs/v3/advanced/server-side-rendering). If your application has a separate SSR entry point, enable `serverHead` there too. Head automatically deduplicates page-managed elements between `@head` and ``, regardless of their order, while preserving other head elements produced by JavaScript SSR. + +> [!NOTE] +> When adding Head to an existing Inertia application, remove title callbacks from `resources/js/app.tsx` and `resources/js/ssr.tsx`. Move overlapping tags from Inertia's [`` component](https://inertiajs.com/docs/v3/the-basics/title-and-meta) and static branding from your root template into Head. Use the self-closing `` shown above so a fallback title does not compete with Head's title. + +The `head` prop is omitted from partial reload responses, so Inertia retains the last full page's head. Use a full visit when changing pages or teams requires fresh metadata. Instant visits likewise retain the current head until the background response arrives. If your application already uses the `head` prop, change its name in a service provider: + +```php +use Hypervel\Head\Facades\Head; + +public function boot(): void +{ + Head::inertia(prop: '_head'); +} +``` + +Then point Inertia at the same prop with `serverHead: '_head'` in both client and SSR entry points. + + +#### Static Inertia Tags + +Most tags should live in defaults, route metadata, or runtime metadata so Head can resolve the right value for each page. Use Inertia globals only for document tags rendered in the first HTML response and left unchanged by Inertia for the rest of the session. + +Register them in a service provider with `Head::inertiaGlobals()`: + +```php +use Hypervel\Head\Facades\Head; +use Hypervel\Head\HeadBuilder; + +Head::inertiaGlobals(function (HeadBuilder $head): void { + $head + ->viewport('width=device-width, initial-scale=1') + ->colorScheme('light dark'); +}); +``` + +Inertia globals are excluded from the `head` prop, rendered without `data-inertia` ownership attributes, and never updated after the first response. These globals are suitable for stable browser hints such as viewport and color scheme. Team branding, icons, manifests, and other values that may change belong in defaults, route metadata, or runtime metadata instead. + +Applications that need the resolved metadata as structured data instead of rendered tags may call `Head::toArray()`. The returned data includes titles, Open Graph values, JSON-LD schemas, and other resolved metadata. + + +### Static Analysis + +Head includes editor declarations for its route macros and a PHPStan extension for checking their named arguments and fluent return types. PHPStan's extension installer discovers the extension automatically. Without extension discovery, include it in your PHPStan configuration: + +```neon +includes: + - vendor/hypervel/head/extension.neon +``` diff --git a/src/docs/inertia.md b/src/docs/inertia.md index 530f3a52be..246d372f58 100644 --- a/src/docs/inertia.md +++ b/src/docs/inertia.md @@ -1953,6 +1953,8 @@ import AppHead from './AppHead' ### Server-Side Head Elements +To manage titles, social metadata, canonical URLs, and structured data from PHP, use [Head](/docs/{{version}}/head#inertia). It renders the initial Blade document and shares metadata with Inertia for subsequent navigation, including defaults resolved from the current request. + You may wish to include default head elements like `` or `<meta>` tags in your root Blade template. This creates two challenges: preventing duplicates when [SSR](#server-side-rendering) is active, and ensuring the client-side `<Head>` component may replace them during navigation. <a name="title-and-meta-ssr-fallback"></a> diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index b9b8da7fce..1fca13616d 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -34,6 +34,7 @@ - [JSON:API Resources](#jsonapi-resources) - [CSRF Protection](#csrf-protection) - [Fortify](#fortify) + - [Head](#head) - [Passport](#passport) - [Pennant](#pennant) - [Permission](#permission) @@ -603,6 +604,11 @@ User models that use Fortify's `TwoFactorAuthenticatable` trait must also implem Fortify ignores Laravel's `fortify.passwords` setting. Declare the password reset broker with the guard's `passwords` key in `config/auth.php` instead. See [password resets](/docs/{{version}}/fortify#password-resets). Laravel's deprecated `Laravel\Fortify\Rules\Password` rule is not available; use `Hypervel\Validation\Rules\Password`. +<a name="head"></a> +### Head + +Head defaults, error definitions, and Inertia globals are shared for the worker lifetime. Register them during provider boot. Move request-dependent defaults and error definitions to `resolveDefaultsUsing()` and `resolveErrorsUsing()` so they read the current request when metadata is rendered. See [runtime defaults](/docs/{{version}}/head#runtime-defaults). + <a name="passport"></a> ### Passport 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<int, Closure(SymfonyResponse, Throwable, Request): mixed> + */ + 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/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..565530d2e4 --- /dev/null +++ b/src/head/README.md @@ -0,0 +1,14 @@ +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). + +An unset `og:url` falls back to the canonical URL when a page defines Open Graph metadata; explicit Open Graph URLs take precedence. See [Open Graph](https://hypervel.org/docs/head#open-graph). + +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 @@ +<?php + +declare(strict_types=1); + +/** + * Editor helper declarations for the withHead() route macros registered by + * hypervel/head. This file is never autoloaded or executed; editors such as + * VS Code with Intelephense index it so withHead() autocompletes on Hypervel's + * routing classes. Keep the signatures in sync with the macros registered + * by \Hypervel\Head\Routing\HeadRouteMacros and its PHPStan signature. + * + * @see \Hypervel\Head\Routing\HeadRouteMacros + */ + +namespace Hypervel\Routing { + use Hypervel\Head\Enums\RobotsRule; + + class Router + { + /** + * Define head metadata for the route, stored through Hypervel's native + * route metadata API. Provided by hypervel/head. + * + * @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 + */ + 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<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 + */ + 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<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 + */ + 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<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 + */ + 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<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 + */ + 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<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 + */ + 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..0bb43b2b94 --- /dev/null +++ b/src/head/src/Concerns/BuildsHead.php @@ -0,0 +1,505 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Concerns; + +use BackedEnum; +use Hypervel\Contracts\Pagination\CursorPaginator; +use Hypervel\Contracts\Pagination\Paginator; +use Hypervel\Head\Enums\ImageType; +use Hypervel\Head\Enums\Media; +use Hypervel\Head\Enums\OgType; +use Hypervel\Head\Enums\RobotsRule; +use Hypervel\Head\Enums\TwitterCard; +use Hypervel\Head\HeadData; +use Hypervel\Head\Schema\SchemaFactory; +use Hypervel\Head\Schema\SchemaObject; +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\Title; +use Hypervel\Head\Tags\Twitter; + +trait BuildsHead +{ + /** + * Get the head data instance the fluent methods should write to. + */ + abstract protected function headData(): HeadData; + + /** + * Get the schema factory used to resolve schema callbacks. + */ + abstract protected function schemaFactory(): SchemaFactory; + + /** + * Use $exact to render the title without inherited prefix or suffix. + */ + public function title(string $title, ?string $prefix = null, ?string $suffix = null, ?bool $exact = null): static + { + $this->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<int, RobotsRule|string>|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 CursorPaginator<int, mixed>|Paginator<int, mixed> $paginator + */ + public function paginate(Paginator|CursorPaginator $paginator): static + { + $this->headData()->overlayBuilder(PaginationLinks::fromPaginator($paginator)); + + return $this; + } + + /** + * Add localized alternate URLs. + * + * @param array<string, string> $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<string, mixed>|callable(SchemaFactory): (array<string, mixed>|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<string, null|BackedEnum|bool|float|int|string> $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<string, null|bool|float|int|string> $attributes + * @return array<string, null|bool|float|int|string> + */ + 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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Hypervel\Context\ReplicableContext; + +/** + * The request-scoped head data and response status. + */ +class CurrentHead implements ReplicableContext +{ + protected HeadData $data; + + protected ?int $status = null; + + /** + * Create the current head state. + */ + public function __construct() + { + $this->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/EventAttendanceMode.php b/src/head/src/Enums/EventAttendanceMode.php new file mode 100644 index 0000000000..f7d440ba52 --- /dev/null +++ b/src/head/src/Enums/EventAttendanceMode.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum EventAttendanceMode: string +{ + case Mixed = 'MixedEventAttendanceMode'; + case Offline = 'OfflineEventAttendanceMode'; + case Online = 'OnlineEventAttendanceMode'; + + /** + * Get the schema.org attendance mode URL. + */ + public function url(): string + { + return 'https://schema.org/' . $this->value; + } +} diff --git a/src/head/src/Enums/EventStatus.php b/src/head/src/Enums/EventStatus.php new file mode 100644 index 0000000000..7f34153237 --- /dev/null +++ b/src/head/src/Enums/EventStatus.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum EventStatus: string +{ + case Cancelled = 'EventCancelled'; + case MovedOnline = 'EventMovedOnline'; + case Postponed = 'EventPostponed'; + case Rescheduled = 'EventRescheduled'; + case Scheduled = 'EventScheduled'; + + /** + * Get the schema.org event status URL. + */ + public function url(): string + { + return 'https://schema.org/' . $this->value; + } +} 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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum ImageType: string +{ + case Avif = 'image/avif'; + case Gif = 'image/gif'; + case Ico = 'image/x-icon'; + case Jpeg = 'image/jpeg'; + case Png = 'image/png'; + case Svg = 'image/svg+xml'; + case Webp = 'image/webp'; +} diff --git a/src/head/src/Enums/Media.php b/src/head/src/Enums/Media.php new file mode 100644 index 0000000000..b3afe11683 --- /dev/null +++ b/src/head/src/Enums/Media.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum Media: string +{ + case Light = '(prefers-color-scheme: light)'; + case Dark = '(prefers-color-scheme: dark)'; + case Portrait = '(orientation: portrait)'; + case Landscape = '(orientation: landscape)'; +} diff --git a/src/head/src/Enums/OfferAvailability.php b/src/head/src/Enums/OfferAvailability.php new file mode 100644 index 0000000000..06d720e479 --- /dev/null +++ b/src/head/src/Enums/OfferAvailability.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum OfferAvailability: string +{ + case BackOrder = 'BackOrder'; + case Discontinued = 'Discontinued'; + case InStock = 'InStock'; + case InStoreOnly = 'InStoreOnly'; + case LimitedAvailability = 'LimitedAvailability'; + case MadeToOrder = 'MadeToOrder'; + case OnlineOnly = 'OnlineOnly'; + case OutOfStock = 'OutOfStock'; + case PreOrder = 'PreOrder'; + case PreSale = 'PreSale'; + case Reserved = 'Reserved'; + case SoldOut = 'SoldOut'; + + /** + * Get the schema.org availability URL. + */ + public function url(): string + { + return 'https://schema.org/' . $this->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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum OgType: string +{ + case Website = 'website'; + case Article = 'article'; + case Book = 'book'; + case Profile = 'profile'; + case MusicSong = 'music.song'; + case MusicAlbum = 'music.album'; + case MusicPlaylist = 'music.playlist'; + case MusicRadioStation = 'music.radio_station'; + case VideoMovie = 'video.movie'; + case VideoEpisode = 'video.episode'; + case VideoTvShow = 'video.tv_show'; + case VideoOther = 'video.other'; +} diff --git a/src/head/src/Enums/RobotsRule.php b/src/head/src/Enums/RobotsRule.php new file mode 100644 index 0000000000..a9b3eb490e --- /dev/null +++ b/src/head/src/Enums/RobotsRule.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum RobotsRule: string +{ + /** + * Shorthand for index, follow. + */ + case All = 'all'; + + /** + * Allow indexing. + */ + case Index = 'index'; + + /** + * Prevent indexing. + */ + case NoIndex = 'noindex'; + + /** + * Allow following links. + */ + case Follow = 'follow'; + + /** + * Prevent following links. + */ + case NoFollow = 'nofollow'; + + /** + * Shorthand for noindex, nofollow. + */ + case None = 'none'; + + /** + * Don't show a cached copy. + */ + case NoArchive = 'noarchive'; + + /** + * Don't show a text snippet or video preview. + */ + case NoSnippet = 'nosnippet'; + + /** + * Don't index images on the page. + */ + case NoImageIndex = 'noimageindex'; + + /** + * Don't offer translation. + */ + case NoTranslate = 'notranslate'; + + /** + * Index the page when it's embedded through iframes. + */ + case IndexIfEmbedded = 'indexifembedded'; +} diff --git a/src/head/src/Enums/TwitterCard.php b/src/head/src/Enums/TwitterCard.php new file mode 100644 index 0000000000..f6c0ecd5f1 --- /dev/null +++ b/src/head/src/Enums/TwitterCard.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Enums; + +enum TwitterCard: string +{ + case Summary = 'summary'; + case SummaryWithLargeImage = 'summary_large_image'; + case App = 'app'; + case Player = 'player'; +} diff --git a/src/head/src/ErrorPages.php b/src/head/src/ErrorPages.php new file mode 100644 index 0000000000..6c24998da2 --- /dev/null +++ b/src/head/src/ErrorPages.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Closure; +use Hypervel\Head\Routing\RouteAttributeParser; + +class ErrorPages +{ + protected HeadData $defaults; + + /** @var array<int, HeadData> */ + 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<int|string, mixed> $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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Exceptions; + +use InvalidArgumentException; + +class InvalidSchema extends InvalidArgumentException +{ +} diff --git a/src/head/src/Facades/Head.php b/src/head/src/Facades/Head.php new file mode 100644 index 0000000000..6764bc4cdf --- /dev/null +++ b/src/head/src/Facades/Head.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Facades; + +use BackedEnum; +use Hypervel\Head\HeadManager; +use Hypervel\Support\Facades\Facade; + +/** + * @method static \Hypervel\Head\HeadManager alternates(array<string, string> $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<string, null|BackedEnum|bool|float|int|string> $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\CursorPaginator<int, mixed>|\Hypervel\Contracts\Pagination\Paginator<int, mixed> $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<int, \Hypervel\Head\Enums\RobotsRule|string>|\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<string, mixed> toArray(int|null $status = null) + * @method static array<int, string> toElements(int|null $status = null) + * @method static string toHtml(int|null $status = null) + * @method static array<int, string> 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<int, string> + */ + 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..1009070ef8 --- /dev/null +++ b/src/head/src/Facades/Schema.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Facades; + +use Hypervel\Head\Schema\Article; +use Hypervel\Head\Schema\BlogPosting; +use Hypervel\Head\Schema\Brand; +use Hypervel\Head\Schema\Breadcrumbs; +use Hypervel\Head\Schema\Event; +use Hypervel\Head\Schema\Faq; +use Hypervel\Head\Schema\Offer; +use Hypervel\Head\Schema\Organization; +use Hypervel\Head\Schema\Person; +use Hypervel\Head\Schema\Product; +use Hypervel\Head\Schema\SchemaFactory; +use Hypervel\Head\Schema\WebPage; +use Hypervel\Head\Schema\WebSite; +use Hypervel\Support\Facades\Facade; + +/** + * @method static \Hypervel\Head\Schema\Article article() + * @method static \Hypervel\Head\Schema\BlogPosting blogPosting() + * @method static \Hypervel\Head\Schema\Brand brand() + * @method static \Hypervel\Head\Schema\Breadcrumbs breadcrumbs() + * @method static \Hypervel\Head\Schema\Event event() + * @method static \Hypervel\Head\Schema\Faq faq() + * @method static \Hypervel\Head\Schema\Offer offer() + * @method static \Hypervel\Head\Schema\Organization organization() + * @method static \Hypervel\Head\Schema\Person person() + * @method static \Hypervel\Head\Schema\Place place() + * @method static \Hypervel\Head\Schema\Product product() + * @method static \Hypervel\Head\Schema\WebPage webPage() + * @method static \Hypervel\Head\Schema\WebSite webSite() + * + * @see \Hypervel\Head\Schema\SchemaFactory + * + * @mixin SchemaFactory + */ +class Schema extends Facade +{ + /** + * Get methods that should be excluded from the generated facade docblock. + * + * The SchemaFactory mixin preserves class-string and generic types + * that generated method tags flatten. + * + * @return array<int, string> + */ + 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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Transient; +use Hypervel\Head\Concerns\BuildsHead; +use Hypervel\Head\Schema\SchemaFactory; +use Hypervel\Support\Traits\Conditionable; + +class HeadBuilder implements Transient +{ + use BuildsHead; + use Conditionable; + + /** + * Create a builder for the given head data. + */ + public function __construct( + protected HeadData $data, + protected ?SchemaFactory $schemaFactory = null, + ) { + } + + /** + * Get the head data instance the fluent methods should write to. + */ + protected function headData(): HeadData + { + return $this->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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Hypervel\Contracts\Container\Transient; +use Hypervel\Head\Tags\TagBuilder; + +/** + * Normalized head state: a collection of tag builders with layer semantics. + */ +class HeadData implements Transient +{ + /** @var array<class-string<TagBuilder>, TagBuilder> */ + protected array $builders = []; + + /** + * Get all tag builders. + * + * @return array<class-string<TagBuilder>, TagBuilder> + */ + public function builders(): array + { + return $this->builders; + } + + /** + * Get an existing tag builder. + * + * @param class-string<TagBuilder> $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<TBuilder> $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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Closure; +use Hypervel\Context\CoroutineContext; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Htmlable; +use Hypervel\Head\Concerns\BuildsHead; +use Hypervel\Head\Rendering\HeadRenderer; +use Hypervel\Head\Routing\RouteAttributeParser; +use Hypervel\Head\Schema\SchemaFactory; +use Hypervel\Head\Tags\Canonical; +use Hypervel\Head\Tags\TagBuilder; +use Hypervel\Http\Request; +use Hypervel\Inertia\InertiaState; +use Hypervel\Routing\Route; +use Hypervel\Support\HtmlString; +use Hypervel\Support\Traits\Conditionable; +use Hypervel\Support\Traits\Macroable; + +/** + * @implements Arrayable<array<string, mixed>> + */ +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<TagBuilder> $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<string, mixed> + */ + public function toArray(?int $status = null): array + { + return $this->renderer->toArray($this->resolveDocument($status), $this->request()); + } + + /** + * Render the document head elements. + * + * @return array<int, string> + */ + 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<int, string> + */ + 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..50526bbea8 --- /dev/null +++ b/src/head/src/HeadServiceProvider.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head; + +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Head\Inertia\Integration as InertiaIntegration; +use Hypervel\Head\Rendering\HeadRenderer; +use Hypervel\Head\Routing\HeadRouteMacros; +use Hypervel\Head\Schema\SchemaFactory; +use Hypervel\Http\Request; +use Hypervel\Support\Facades\Blade; +use Hypervel\Support\ServiceProvider; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Throwable; + +class HeadServiceProvider extends ServiceProvider +{ + /** + * Register the package services. + */ + public function register(): void + { + $this->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 === '' + ? "<?php echo app('head')->renderForView(); ?>" + : "<?php echo app('head')->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 (ExceptionHandler $handler): void { + $head = $this->app->make(HeadManager::class); + + if (is_callable([$handler, 'renderable'])) { + $handler->renderable(function (Throwable $exception, Request $request) use ($head): void { + $head->status( + $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : 500 + ); + }); + } + + if (is_callable([$handler, 'beforeResponding'])) { + $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..ee5c55ae6f --- /dev/null +++ b/src/head/src/Inertia/Integration.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Inertia; + +use Hypervel\Contracts\Foundation\Application; +use Hypervel\Contracts\Http\Kernel; +use Hypervel\Head\HeadManager; +use Hypervel\Head\Rendering\HeadRenderer; +use Hypervel\Inertia\InertiaServiceProvider; +use Hypervel\Inertia\Ssr\Gateway; + +class Integration +{ + /** + * Create the Inertia integration registrar. + */ + public function __construct(protected Application $app) + { + } + + /** + * Register Hypervel Head's optional Inertia integration. + * + * Boot-only. Gateway bindings and middleware affect every request in the worker. + */ + public function register(): void + { + if ($this->app->getProviders(InertiaServiceProvider::class) === []) { + return; + } + + $this->decorateSsrGateway(); + $this->registerMiddleware(); + } + + /** + * Share head elements during every request in coroutine-local Inertia state. + */ + protected function registerMiddleware(): void + { + $prepend = static function (Kernel $kernel): void { + // Register the lazy prop before global middleware can render an error. + $kernel->prependMiddleware(ShareHead::class); + }; + + $this->app->afterResolving(Kernel::class, $prepend); + + if ($this->app->resolved(Kernel::class)) { + $prepend($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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Inertia; + +use Closure; +use Hypervel\Head\HeadManager; +use Hypervel\Http\Request; +use Hypervel\Inertia\Inertia; +use Symfony\Component\HttpFoundation\Response; + +class ShareHead +{ + /** + * Create the head-sharing middleware. + */ + public function __construct(protected HeadManager $head) + { + } + + /** + * Handle an incoming request. + * + * @param Closure(Request): Response $next + */ + public function handle(Request $request, Closure $next): Response + { + // A plain callable (rather than Inertia::always) keeps the elements + // off the wire during partial reloads; the client retains the head + // from the last full visit. + Inertia::share( + $this->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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Inertia; + +use Closure; +use Hypervel\Head\HeadManager; +use Hypervel\Head\Rendering\HeadRenderer; +use Hypervel\Inertia\Ssr\ConfiguresSsrRequests; +use Hypervel\Inertia\Ssr\DisablesSsr; +use Hypervel\Inertia\Ssr\ExcludesSsrPaths; +use Hypervel\Inertia\Ssr\Gateway; +use Hypervel\Inertia\Ssr\HasHealthCheck; +use Hypervel\Inertia\Ssr\Response; +use Hypervel\Support\Arr; +use LogicException; + +class UniqueHeadGateway implements ConfiguresSsrRequests, DisablesSsr, ExcludesSsrPaths, Gateway, HasHealthCheck +{ + /** + * Create the SSR gateway decorator. + */ + public function __construct( + protected Gateway $gateway, + protected HeadManager $head, + protected HeadRenderer $renderer, + ) { + } + + /** + * Dispatch the page to the SSR engine, stripping the page-managed head + * elements from the response while preserving any other elements + * produced by JavaScript SSR. + * + * @param array<string, mixed> $page + */ + public function dispatch(array $page): ?Response + { + $response = $this->gateway->dispatch($page); + + if (is_null($response)) { + return null; + } + + /** @var array<int, string> $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<int, string>|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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\PHPStan; + +use Hypervel\Head\Enums\RobotsRule; + +/** + * Declare the routing macro parameters for analysis without running provider boot. + */ +interface RoutingMacros +{ + /** + * Define head metadata for the route, stored through Hypervel's native + * route metadata API. Provided by hypervel/head. + * + * @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 + */ + 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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\PHPStan; + +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 PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\MethodsClassReflectionExtension; +use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\ObjectType; +use PHPStan\Type\ThisType; + +class WithHeadExtension implements MethodsClassReflectionExtension +{ + /** + * Create the routing macro reflection extension. + */ + public function __construct(protected readonly ReflectionProvider $reflectionProvider) + { + } + + /** + * Determine whether the class receives the withHead routing macro. + */ + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + if ($methodName !== 'withHead' || $classReflection->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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\PHPStan; + +use PHPStan\Reflection\ClassMemberReflection; +use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedParametersAcceptor; +use PHPStan\Reflection\FunctionVariant; +use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\TrinaryLogic; +use PHPStan\Type\Type; + +/** + * Describe the routing macro using its native signature and receiver-specific result. + */ +class WithHeadMethodReflection implements MethodReflection +{ + /** + * Create a routing macro method reflection. + */ + public function __construct( + private readonly ClassReflection $declaringClass, + private readonly MethodReflection $signature, + private readonly Type $returnType, + private readonly bool $isStatic, + ) { + } + + /** + * Return the method name. + */ + public function getName(): string + { + return $this->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<ParametersAcceptor> + */ + 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 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Rendering; + +use Hypervel\Head\HeadData; +use Hypervel\Head\Schema\SchemaValidator; +use Hypervel\Head\TagRegistry; +use Hypervel\Head\Tags\TagBuilder; +use Hypervel\Http\Request; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; + +class HeadRenderer +{ + protected TagRenderer $tags; + + /** + * Create the head renderer. + */ + public function __construct( + protected SchemaValidator $schemas, + protected TagRegistry $registry, + ?TagRenderer $tags = null, + ) { + $this->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<int, string> + */ + 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<int, string> + */ + 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<int, string> $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<int, string> + */ + 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<int, string> $elements + * @return array<int, string> + */ + 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, '<title ') + ? 'title' + : ($key === '' ? '' : 'tag:' . $key); + + if ($key === '') { + $unique[] = $element; + + continue; + } + + if (isset($indexes[$key])) { + $unique[$indexes[$key]] = $element; + + continue; + } + + $indexes[$key] = count($unique); + $unique[] = $element; + } + + return $unique; + } + + /** + * Render every builder in the resolved head. + * + * @return array<int, string> + */ + 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..154538a78d --- /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<int|string, string> $items breadcrumb names keyed to their URLs + */ + public function items(array $items): static + { + foreach ($items as $name => $url) { + $this->item((string) $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..40b02a548e --- /dev/null +++ b/src/head/src/Schema/Event.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use DateTimeInterface; +use Hypervel\Head\Enums\EventAttendanceMode; +use Hypervel\Head\Enums\EventStatus; +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 attendance mode. + */ + public function eventAttendanceMode(EventAttendanceMode $mode): static + { + return $this->set('eventAttendanceMode', $mode->url()); + } + + /** + * Set the event status. + */ + public function eventStatus(EventStatus $status): static + { + return $this->set('eventStatus', $status->url()); + } + + /** + * Set the event images. + * + * @param array<int, string>|string $image + */ + public function image(string|array $image): static + { + return $this->set('image', $image); + } + + /** + * Set the event location. A string is treated as the name of a physical place. + */ + public function location(SchemaObject|string $location): static + { + return $this->set('location', is_string($location) ? (new Place)->name($location) : $location); + } + + /** + * 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 performers. + * + * @param array<int, Organization|Person>|Organization|Person $performer + */ + public function performer(Organization|Person|array $performer): static + { + return $this->set('performer', $performer); + } + + /** + * 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..75e2391a37 --- /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<int|string, string> $questions questions keyed to their answers + */ + public function questions(array $questions): static + { + foreach ($questions as $name => $answer) { + $this->question((string) $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/Place.php b/src/head/src/Schema/Place.php new file mode 100644 index 0000000000..ceb863f2d7 --- /dev/null +++ b/src/head/src/Schema/Place.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Schema; + +use Hypervel\Head\SchemaType; + +#[SchemaType('Place')] +class Place extends SchemaObject +{ + /** + * Set the place name. + */ + public function name(string $name): static + { + return $this->set('name', $name); + } + + /** + * Set the place address. + * + * @param array<string, mixed>|SchemaObject|string $address + */ + public function address(SchemaObject|array|string $address): static + { + return $this->set('address', $address); + } + + /** + * Set the place 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..6a30ca7cc6 --- /dev/null +++ b/src/head/src/Schema/SchemaFactory.php @@ -0,0 +1,92 @@ +<?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() + * @method Place place() + */ +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); + $this->register(Place::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..307dd021ee --- /dev/null +++ b/src/head/src/Schema/SchemaObject.php @@ -0,0 +1,146 @@ +<?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 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|string, 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 === [] ? true : $parameters[array_key_first($parameters)]); + } + + /** + * 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 === [] + ? $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..c7d4667164 --- /dev/null +++ b/src/head/src/Schema/SchemaValidator.php @@ -0,0 +1,72 @@ +<?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 ($value instanceof SchemaObject) { + $value = $value->toArray(); + } + + 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..95caf73433 --- /dev/null +++ b/src/head/src/TagRegistry.php @@ -0,0 +1,122 @@ +<?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.'); + } + + if (in_array($builder, $this->extensions, true)) { + return $this; + } + + $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), + )); + } + + $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..74fa464958 --- /dev/null +++ b/src/head/src/Tags/Canonical.php @@ -0,0 +1,221 @@ +<?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 + { + $protocolRelative = str_starts_with($url, '//'); + $relative = ! $protocolRelative && ! preg_match('~^https?://~i', $url); + + if ($protocolRelative && ($forceHttps || $request !== null)) { + $url = ($forceHttps ? 'https' : $request->getScheme()) . ':' . $url; + } + + 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:\/\//i', '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..05f0150c81 --- /dev/null +++ b/src/head/src/Tags/FeedLinks.php @@ -0,0 +1,201 @@ +<?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) && ! is_string($value))) { + return null; + } + + $links = new self; + + foreach (self::feedAttributes(is_string($value) ? [$value] : $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'] === '' ? null : $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_int($href) && is_string($feed)) { + $this->feed($feed, ''); + + return true; + } + + if (is_string($href) && is_string($feed)) { + $this->feed($href, $feed); + + return true; + } + + if (! is_array($feed) + || ! is_string($title = $feed['title'] ?? '') + || is_null($url = self::routeAttributeHref($href, $feed))) { + return false; + } + + $this->feed($url, $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..0b8d05e1d0 --- /dev/null +++ b/src/head/src/Tags/MetaTags.php @@ -0,0 +1,229 @@ +<?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']) + && $this->resolveAttribute($meta['key'], $meta['property'] ?? null) === $this->resolveAttribute($meta['key'], null)) { + return self::inertiaAliases()[$meta['key']] ?? $meta['key']; + } + + return 'meta:' . substr(hash('xxh128', $this->tagKey($meta['key'], $meta['property'] ?? null, $meta['media'] ?? null)), 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..468964e761 --- /dev/null +++ b/src/head/src/Tags/OpenGraph.php @@ -0,0 +1,493 @@ +<?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, + * description, and canonical URL. + * + * @return array<string, string> + */ + public function render(?string $title = null, ?string $description = null, ?string $url = 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; + } + + if ($url !== null && $url !== '' && ! isset($properties['url'])) { + $properties['url'] = $url; + } + + 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(), $head->canonical()); + } + + /** + * 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(), $head->canonical()); + $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, ?string $url = null): array + { + return array_filter([ + ...$this->render($title, $description, $url), + '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..c9ec3fe708 --- /dev/null +++ b/src/head/src/Tags/PaginationLinks.php @@ -0,0 +1,122 @@ +<?php + +declare(strict_types=1); + +namespace Hypervel\Head\Tags; + +use Hypervel\Contracts\Pagination\CursorPaginator; +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'; + } + + /** + * Get the supported route attribute names. + * + * @return array<int, string> + */ + public static function routeAttributeKeys(): array + { + return []; + } + + /** + * Create previous and next links from a paginator. + * + * @param CursorPaginator<int, mixed>|Paginator<int, mixed> $paginator + */ + public static function fromPaginator(Paginator|CursorPaginator $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; + } + + /** + * Replace the lower layer's pagination links. + */ + public function overlayOn(?TagBuilder $base): static + { + // Pagination describes one page; merging would retain relations that no longer exist. + return $this; + } + + /** + * 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..0a1ac9346c --- /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_int($href) && 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_int($href) && 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_int($href) && 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/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<array-key, mixed> buildContextForException(Throwable $e) * @method static \Hypervel\Foundation\Exceptions\Handler buildContextUsing(\Closure $contextCallback) * @method static array contextForException(Throwable $e) 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/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/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([ 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..7152775b56 --- /dev/null +++ b/tests/Head/Feature/ErrorPagesTest.php @@ -0,0 +1,183 @@ +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], + ]; + } + + public function testSharesHeadWhenGlobalMiddlewareRejectsTheRequest(): void + { + $maintenance = new ArrayMaintenanceMode; + $maintenance->activate(['status' => 503]); + $this->app->instance(MaintenanceMode::class, $maintenance); + + Head::errors(fn (ErrorPages $errors): ErrorPages => $errors->status(503, title: 'Maintenance')); + Inertia::handleExceptionsUsing(fn (ExceptionResponse $response): ExceptionResponse => $response->render('Error')); + + $this->get('/maintenance', ['X-Inertia' => 'true']) + ->assertStatus(503) + ->assertJsonPath('component', 'Error') + ->assertJsonPath('props.head.0', 'Maintenance'); + } +} + +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..bfc2891873 --- /dev/null +++ b/tests/Head/Feature/HeadDataTest.php @@ -0,0 +1,73 @@ +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); + 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..659c98e071 --- /dev/null +++ b/tests/Head/Feature/HeadServiceProviderTest.php @@ -0,0 +1,100 @@ + + */ + 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); + } + + public function testSupportsCustomExceptionHandlersWithoutRenderingHooks(): void + { + $handler = $this->createStub(ExceptionHandler::class); + $this->app->singleton(ExceptionHandler::class, fn (): ExceptionHandler => $handler); + + $this->assertSame($handler, $this->app->make(ExceptionHandler::class)); + + Head::errors(fn (ErrorPages $errors): ErrorPages => $errors->status(404, title: 'Not found')); + Head::status(404); + + $this->assertSame('Not found', Head::toHtml()); + } +} diff --git a/tests/Head/Feature/InertiaCustomPropTest.php b/tests/Head/Feature/InertiaCustomPropTest.php new file mode 100644 index 0000000000..4cfe80f0e2 --- /dev/null +++ b/tests/Head/Feature/InertiaCustomPropTest.php @@ -0,0 +1,55 @@ + + */ + protected function getPackageProviders(Application $app): array + { + return [HeadCustomInertiaServiceProvider::class, HeadServiceProvider::class]; + } + + /** + * Configure a custom shared head prop before providers boot. + */ + protected function defineEnvironment(Application $app): void + { + parent::defineEnvironment($app); + + $app->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'); + } +} + +class HeadCustomInertiaServiceProvider extends InertiaServiceProvider +{ +} 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..bc7a62fcdc --- /dev/null +++ b/tests/Head/Feature/Rendering/HeadRendererTest.php @@ -0,0 +1,185 @@ +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 testOmitsAnEmptyFeedTitle(): void + { + Head::feed('/feed', ''); + + $this->assertSame('', Head::toHtml()); + } + + 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()); + } + + #[TestWith([30, 3, ['prev' => '/posts?page=2']])] + #[TestWith([1, 1, []])] + public function testNewPaginationReplacesTheCompleteRelationSet(int $total, int $page, array $expected): void + { + Head::paginate(new LengthAwarePaginator([], 30, 10, 2, ['path' => '/posts'])); + Head::paginate(new LengthAwarePaginator([], $total, 10, $page, ['path' => '/posts'])); + + $this->assertSame($expected, Head::toArray()['links']['pagination']); + } + + public function testRendersCursorPaginationLinks(): void + { + $paginator = new CursorPaginator( + [['id' => 3], ['id' => 4], ['id' => 5]], + 2, + new Cursor(['id' => 2]), + ['path' => '/posts', 'parameters' => ['id']], + ); + + Head::paginate($paginator); + + $this->assertSame([ + 'prev' => $paginator->previousPageUrl(), + 'next' => $paginator->nextPageUrl(), + ], Head::toArray()['links']['pagination']); + } +} 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..ab252fde7b --- /dev/null +++ b/tests/Head/Feature/Routing/RouteHeadTest.php @@ -0,0 +1,256 @@ +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'); + } + + #[TestWith(['/feed', ''])] + #[TestWith([['/feed', '/news'], '' . "\n" . ''])] + #[TestWith([['href' => '/feed.atom', 'type' => 'atom'], ''])] + #[TestWith([['href' => '/feed', 'title' => null], ''])] + #[TestWith([['/feed' => 'News'], ''])] + public function testParsesFeedRouteValues(string|array $feed, string $expected): void + { + Route::get('/feeds', fn (): string => Head::toHtml())->withHead(feed: $feed); + + $this->get('/feeds')->assertOk()->assertContent($expected); + } + + #[TestWith(['preload', ['/app.js' => 'script']])] + #[TestWith(['prefetch', ['/app.js' => 'script']])] + #[TestWith(['preconnect', ['https://cdn.example.com' => 'anonymous']])] + #[TestWith(['feed', ['href' => '/feed', 'title' => 42]])] + public function testRejectsInvalidRouteLinkValues(string $key, array $value): void + { + Route::get('/invalid-link', fn (): string => Head::toHtml())->withHead(...[$key => $value]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid value for route head attribute [{$key}]."); + $this->withoutExceptionHandling()->get('/invalid-link'); + } + + public function testPaginationIsNotAdvertisedAsARouteAttribute(): void + { + Route::get('/pagination', fn (): string => Head::toHtml())->withHead(extensions: ['pagination' => []]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown route head attribute [pagination].'); + $this->withoutExceptionHandling()->get('/pagination'); + } + + 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/EventSchemaTest.php b/tests/Head/Feature/Schema/EventSchemaTest.php new file mode 100644 index 0000000000..0cc028ad42 --- /dev/null +++ b/tests/Head/Feature/Schema/EventSchemaTest.php @@ -0,0 +1,97 @@ +name('Laracon US') + ->startDate('2026-08-20T09:00:00-05:00') + ->endDate(new DateTimeImmutable('2026-08-21T17:00:00-05:00')) + ->location( + Schema::place() + ->name('Music City Center') + ->address('201 Rep. John Lewis Way S, Nashville, TN') + ) + ); + + $html = Head::toHtml(); + $this->assertStringContainsString('"@type":"Event"', $html); + $this->assertStringContainsString('"name":"Laracon US"', $html); + $this->assertStringContainsString('"startDate":"2026-08-20T09:00:00-05:00"', $html); + $this->assertStringContainsString('"endDate":"2026-08-21T17:00:00-05:00"', $html); + $this->assertStringContainsString('"location":{"@type":"Place","name":"Music City Center","address":"201 Rep. John Lewis Way S, Nashville, TN"}', $html); + } + + public function testTreatsAStringLocationAsTheNameOfAPlace(): void + { + $event = Schema::event()->location('Music City Center'); + + $this->assertSame(['@type' => 'Place', 'name' => 'Music City Center'], $event->toArray()['location']); + } + + public function testSetsEventStatusAndAttendanceModeFromSchemaOrgEnumValues(): void + { + $event = Schema::event() + ->eventStatus(EventStatus::Scheduled) + ->eventAttendanceMode(EventAttendanceMode::Offline); + + $this->assertSame([ + '@type' => 'Event', + 'eventStatus' => 'https://schema.org/EventScheduled', + 'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode', + ], $event->toArray()); + } + + public function testSetsEventImagesPerformersOrganizerAndOffers(): void + { + $event = Schema::event() + ->image(['https://example.com/hero.jpg', 'https://example.com/stage.jpg']) + ->performer([Schema::person()->name('Taylor Otwell'), Schema::organization()->name('Hypervel')]) + ->organizer(Schema::organization()->name('Hypervel')) + ->offers( + Schema::offer() + ->price(499) + ->currency('USD') + ->availability(OfferAvailability::InStock) + ); + + $this->assertSame([ + '@type' => 'Event', + 'image' => ['https://example.com/hero.jpg', 'https://example.com/stage.jpg'], + 'performer' => [ + ['@type' => 'Person', 'name' => 'Taylor Otwell'], + ['@type' => 'Organization', 'name' => 'Hypervel'], + ], + 'organizer' => ['@type' => 'Organization', 'name' => 'Hypervel'], + 'offers' => ['@type' => 'Offer', 'price' => 499, 'priceCurrency' => 'USD', 'availability' => 'https://schema.org/InStock'], + ], $event->toArray()); + } + + public function testRegistersPlaceAsAFirstClassFactoryMethod(): void + { + $this->assertInstanceOf(Place::class, Schema::place()); + $this->assertSame(['@type' => 'Place', 'name' => 'Venue', 'url' => 'https://example.com/venue'], Schema::place()->name('Venue')->url('https://example.com/venue')->toArray()); + } + + public function testIncludesAllSchemaOrgEventStatusAndAttendanceModeValues(): void + { + $this->assertSame('https://schema.org/EventCancelled', EventStatus::Cancelled->url()); + $this->assertSame('https://schema.org/EventMovedOnline', EventStatus::MovedOnline->url()); + $this->assertSame('https://schema.org/MixedEventAttendanceMode', EventAttendanceMode::Mixed->url()); + } +} diff --git a/tests/Head/Feature/Schema/SchemaTest.php b/tests/Head/Feature/Schema/SchemaTest.php new file mode 100644 index 0000000000..7bae7f6e68 --- /dev/null +++ b/tests/Head/Feature/Schema/SchemaTest.php @@ -0,0 +1,274 @@ +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', + '2024' => 'https://example.com/shop/2024', + ]) + ); + + $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); + $this->assertStringContainsString('"position":4,"name":"2024","item":"https://example.com/shop/2024"', $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.', + '2024' => 'A year.', + ]) + ); + + $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); + $this->assertStringContainsString('"name":"2024"', $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); + } + + #[DataProvider('magicSetterValues')] + public function testMagicSchemaSettersPreserveSuppliedValues(array $arguments, mixed $expected): void + { + $schema = Schema::make('videoObject')->duration(...$arguments); + + $this->assertSame($expected, $schema->toArray()['duration']); + } + + /** + * Supply positional, named, and omitted magic setter values. + * + * @return iterable, mixed}> + */ + public static function magicSetterValues(): iterable + { + yield 'omitted' => [[], true]; + yield 'named' => [['value' => 'PT10M'], 'PT10M']; + yield 'false' => [[false], false]; + yield 'zero' => [[0], 0]; + yield 'null' => [[null], null]; + yield 'named null' => [['value' => null], null]; + } + + public function testInfersSchemaTypesFromConcreteClassNames(): void + { + Head::schema(new LocalBusiness)->schema(new NewsArticle); + + $html = Head::toHtml(); + $this->assertStringContainsString('"@type":"LocalBusiness"', $html); + $this->assertStringContainsString('"@type":"NewsArticle"', $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('