*/ private const VALID_SURFACES = array( self::SURFACE_FRONTEND, self::SURFACE_EDITOR, self::SURFACE_SHORTCODE, self::SURFACE_REST_PREVIEW, ); /** * One of the {@see self::SURFACE_*} tokens. Validated at * construction time so an out-of-set value never reaches the * renderer. */ private string $surface; /** * §6.4 template key the renderer should pick. Held as a plain * string; the TemplateResolver enforces the grammar at the * resolution boundary. */ private string $template; /** * BCP-47 locale tag, or null when the consuming surface wants * the renderer to fall back to the site default. */ private ?string $locale; /** * Whether the host page is rendering in AMP mode. */ private bool $isAmp; /** * Whether the request is rendering inside an RSS / Atom feed. */ private bool $isFeed; /** * Whether the consuming surface wants affiliate-tracked URLs * on outgoing links. */ private bool $trackingEnabled; /** * Whether image elements should carry `loading="lazy"` markup. */ private bool $lazyLoadEnabled; /** * Explicit `target` attribute value, or null to defer to the * link-attribute builder's surface-aware default. */ private ?string $linkTarget; /** * Explicit `rel` policy token, or null to defer to the * link-attribute builder's default. */ private ?string $linkRelPolicy; /** * Whether the renderer should emit additional diagnostic * markup. */ private bool $debug; /** * Opaque correlation identifier, or null when the rendering * pass is not correlated. */ private ?string $requestId; /** * Construct a render context with explicit values for every * field. * * The constructor is exhaustive so a programming error * (forgetting to declare a flag on a write, swapping * `linkTarget` / `linkRelPolicy`) surfaces at the type system * boundary rather than at the renderer. Named-argument * construction is the ergonomic entry point for most call * sites; the shared {@see self::frontend()} / {@see self::editor()} * factories cover the common cases. * * Validation summary: * * - `surface` is trimmed and matched against * {@see self::VALID_SURFACES}; out-of-set tokens raise * {@see InvalidArgumentException} carrying the offending * value verbatim and a comma-separated list of the * accepted tokens for diagnosis. * - `template` is trimmed and rejected when empty after * trimming. Format validation lives at the TemplateResolver. * - `locale`, `linkTarget`, `linkRelPolicy`, `requestId`, * when non-null, are trimmed and rejected when the trim * leaves an empty string — "present but empty" is treated * as a programming error. * * @param string $surface §5.2 surface token. * @param string $template §6.4 template key. * @param string|null $locale BCP-47 locale tag. * @param bool $isAmp AMP rendering flag. * @param bool $isFeed Feed rendering flag. * @param bool $trackingEnabled Whether tracked URLs are wanted. * @param bool $lazyLoadEnabled Whether `loading="lazy"` is wanted. * @param string|null $linkTarget Explicit `target` value. * @param string|null $linkRelPolicy Explicit `rel` policy. * @param bool $debug Diagnostic-markup flag. * @param string|null $requestId Correlation identifier. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( string $surface, string $template, ?string $locale, bool $isAmp, bool $isFeed, bool $trackingEnabled, bool $lazyLoadEnabled, ?string $linkTarget, ?string $linkRelPolicy, bool $debug, ?string $requestId ) { $this->surface = self::normalizeSurface( $surface ); $this->template = self::normalizeRequiredString( 'template', $template ); $this->locale = self::normalizeOptionalString( 'locale', $locale ); $this->isAmp = $isAmp; $this->isFeed = $isFeed; $this->trackingEnabled = $trackingEnabled; $this->lazyLoadEnabled = $lazyLoadEnabled; $this->linkTarget = self::normalizeOptionalString( 'linkTarget', $linkTarget ); $this->linkRelPolicy = self::normalizeOptionalString( 'linkRelPolicy', $linkRelPolicy ); $this->debug = $debug; $this->requestId = self::normalizeOptionalString( 'requestId', $requestId ); } /** * Construct a context targeting the live frontend surface. * * Convenience factory for the common case: `surface` is * `frontend`, `lazyLoadEnabled` and `trackingEnabled` default * to true, AMP / feed / debug default to false, and the * remaining fields are accepted as named arguments. Tests and * controllers that need an exotic combination should use the * primary constructor directly. * * @param string $template §6.4 template key. * @param string|null $locale BCP-47 locale tag. * @param string|null $requestId Correlation identifier. */ public static function frontend( string $template, ?string $locale = null, ?string $requestId = null ): self { return new self( self::SURFACE_FRONTEND, $template, $locale, false, false, true, true, null, null, false, $requestId ); } /** * Construct a context targeting the Gutenberg block editor * preview surface. * * Editor previews disable tracking (admin views must not * inflate affiliate attribution counters) and disable * lazy-loading (the editor preview is small and visible on * load). The remaining axes follow the primary constructor's * defaults; callers that need to adjust them can chain * `with*()` helpers. * * @param string $template §6.4 template key. * @param string|null $locale BCP-47 locale tag. * @param string|null $requestId Correlation identifier. */ public static function editor( string $template, ?string $locale = null, ?string $requestId = null ): self { return new self( self::SURFACE_EDITOR, $template, $locale, false, false, false, false, null, null, false, $requestId ); } /** * Closed set of accepted §5.2 surface tokens, in declaration * order. * * Exposed for tests and for diagnostics surfaces that want to * enumerate the valid tokens without duplicating the list. * * @return list */ public static function validSurfaces(): array { return self::VALID_SURFACES; } /** * §5.2 surface token this context targets. */ public function surface(): string { return $this->surface; } /** * §6.4 template key the renderer should pick. */ public function template(): string { return $this->template; } /** * BCP-47 locale tag, or null. */ public function locale(): ?string { return $this->locale; } /** * Whether the host page is rendering in AMP mode. */ public function isAmp(): bool { return $this->isAmp; } /** * Whether the request is rendering inside a feed. */ public function isFeed(): bool { return $this->isFeed; } /** * Whether tracked URLs are wanted on outgoing links. */ public function trackingEnabled(): bool { return $this->trackingEnabled; } /** * Whether image elements should carry `loading="lazy"`. */ public function lazyLoadEnabled(): bool { return $this->lazyLoadEnabled; } /** * Explicit `target` attribute value, or null. */ public function linkTarget(): ?string { return $this->linkTarget; } /** * Explicit `rel` policy token, or null. */ public function linkRelPolicy(): ?string { return $this->linkRelPolicy; } /** * Whether the renderer should emit diagnostic markup. */ public function debug(): bool { return $this->debug; } /** * Opaque correlation identifier, or null. */ public function requestId(): ?string { return $this->requestId; } /** * Convenience guard: report whether the context targets the * live frontend surface. * * Exposed because the §5.2 surface set has a single dominant * "production rendering" token that consuming surfaces * routinely branch on; a dedicated helper keeps those branches * readable without forcing every call site to compare against * the raw string constant. */ public function isFrontend(): bool { return $this->surface === self::SURFACE_FRONTEND; } /** * Convenience guard: report whether the context targets the * Gutenberg block editor preview surface. */ public function isEditor(): bool { return $this->surface === self::SURFACE_EDITOR; } /** * Produce a copy of this context with `template` replaced. * * Used by the ViewModel factory when it picks a more specific * template than the consuming surface defaulted to (for * example downgrading to `single-fallback-card` when the * snapshot is missing required fields). Other fields are * preserved verbatim. * * @param string $template New §6.4 template key. * * @throws InvalidArgumentException When `$template` is empty * after trimming. */ public function withTemplate( string $template ): self { $clone = clone $this; $clone->template = self::normalizeRequiredString( 'template', $template ); return $clone; } /** * Produce a copy of this context with `surface` replaced. * * Used by sub-renderers that need to adjust their rendering * posture (an editor preview embedded inside a `rest_preview` * response, for example). Other fields are preserved verbatim. * * @param string $surface New §5.2 surface token. * * @throws InvalidArgumentException When `$surface` is not a * member of the §5.2 closed * set. */ public function withSurface( string $surface ): self { $clone = clone $this; $clone->surface = self::normalizeSurface( $surface ); return $clone; } /** * Produce a copy of this context with `debug` replaced. * * Used by the diagnostics surface to produce a pair of * "with debug" / "without debug" snapshots for the same * render. Other fields are preserved verbatim. * * @param bool $debug New diagnostic-markup flag. */ public function withDebug( bool $debug ): self { $clone = clone $this; $clone->debug = $debug; return $clone; } /** * Validate and normalize the `surface` field at construction. * * The surface is trimmed first so a carelessly-decoded payload * (trailing newline, padded space) is normalized to the same * canonical form a freshly-constructed surface would carry. * The trimmed value MUST appear in {@see self::VALID_SURFACES}; * anything else raises {@see InvalidArgumentException} carrying * the offending input verbatim and a comma-separated list of * the accepted tokens for diagnosis. * * @param string $surface Raw surface as supplied by the caller. * * @throws InvalidArgumentException When `$surface` is not a * member of the §5.2 closed * set. */ private static function normalizeSurface( string $surface ): string { $trimmed = trim( $surface ); if ( ! in_array( $trimmed, self::VALID_SURFACES, true ) ) { $message = sprintf( 'RenderContext surface must be one of [%s]; got "%s".', implode( ', ', self::VALID_SURFACES ), $surface ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize a required string field at * construction. * * Trims surrounding whitespace and rejects the result when it * is the empty string. Used for the `template` field. * * @param string $field Field name for the error message. * @param string $value Raw value as supplied by the caller. * * @throws InvalidArgumentException When `$value` is empty * after trimming. */ private static function normalizeRequiredString( string $field, string $value ): string { $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'RenderContext %s must be a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * Used for the `locale` / `linkTarget` / `linkRelPolicy` / * `requestId` fields where "present but empty" is a * programming error rather than a meaningful state. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private static function normalizeOptionalString( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'RenderContext %s must be null or a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } }