` element without re-deriving the lazy-load posture from a * sibling RenderContext at render time. * * Field semantics match `specifications/rendering-and-template-spec.md` * §10.4 exactly: * * - `url` is the absolute image URL the renderer stamps onto the * `` attribute. Required, non-empty after * trimming. The DTO does not parse the URL beyond rejecting * the empty string — provider-specific scheme and host * validation lives at the Image fetcher / sanitizer layer. * * - `alt` is the §10.4 "原則として商品タイトル" alternative text * the renderer stamps onto `...`. Required (an * empty alt is conveyed by passing a single space or an * explicit non-empty placeholder; this DTO always carries * non-empty content because the renderer should never emit a * bare `alt=""` for product imagery). * * - `width` and `height` are the intrinsic image dimensions in * CSS pixels. Held as `?int` because not every provider * response carries them, and not every surface (feed, AMP) * wants them stamped. When non-null, both must be strictly * positive — zero and negative values are rejected as a * programming error. * * - `loading` is the §10.4 `loading` attribute value the * renderer stamps onto ``. The closed set * is `'lazy'` (default for §5.2 frontend rendering), * `'eager'` (above-the-fold images), and `'auto'` (defer to * the user agent). Validated at construction so an * out-of-set value never reaches the renderer. * * Instances are immutable and have no `with*()` mutation helpers * because an image view is composed once per render at the * ViewModel-factory boundary. * * @package Affilun\Application\Render\ViewModel\Parts */ declare(strict_types=1); namespace Affilun\Application\Render\ViewModel\Parts; use InvalidArgumentException; /** * Immutable image-view used by §8.2 ProductCardViewModel and the * §10.4 image rendering rules. * * See the file-level docblock for the mapping to * `rendering-and-template-spec.md` §10.4 and the per-field * validation rules. */ final class ImageView { /** * §10.4 `loading="lazy"` token. */ public const LOADING_LAZY = 'lazy'; /** * §10.4 `loading="eager"` token. */ public const LOADING_EAGER = 'eager'; /** * §10.4 `loading="auto"` token (defer to the user agent). */ public const LOADING_AUTO = 'auto'; /** * Closed set of accepted §10.4 `loading` tokens, in * declaration order. * * @var list */ private const VALID_LOADING = array( self::LOADING_LAZY, self::LOADING_EAGER, self::LOADING_AUTO, ); /** * Absolute image URL. */ private string $url; /** * Alternative text for the image. */ private string $alt; /** * Intrinsic image width in CSS pixels, or null. */ private ?int $width; /** * Intrinsic image height in CSS pixels, or null. */ private ?int $height; /** * One of the {@see self::LOADING_*} tokens. */ private string $loading; /** * Construct an image view with explicit values for every * field. * * Validation summary: * * - `url` and `alt` are trimmed and rejected when empty * after trimming. * - `width` and `height`, when non-null, must be strictly * positive; zero and negative values are rejected. * - `loading` is trimmed and matched against * {@see self::VALID_LOADING}; out-of-set tokens raise * {@see InvalidArgumentException}. * * @param string $url Absolute image URL. * @param string $alt Alternative text. * @param int|null $width Intrinsic width, or null. * @param int|null $height Intrinsic height, or null. * @param string $loading One of {@see self::VALID_LOADING}. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( string $url, string $alt, ?int $width, ?int $height, string $loading ) { $this->url = self::normalizeRequiredString( 'url', $url ); $this->alt = self::normalizeRequiredString( 'alt', $alt ); $this->width = self::normalizeOptionalDimension( 'width', $width ); $this->height = self::normalizeOptionalDimension( 'height', $height ); $this->loading = self::normalizeLoading( $loading ); } /** * Closed set of accepted §10.4 `loading` tokens, in * declaration order. * * @return list */ public static function validLoading(): array { return self::VALID_LOADING; } /** * Absolute image URL. */ public function url(): string { return $this->url; } /** * Alternative text for the image. */ public function alt(): string { return $this->alt; } /** * Intrinsic image width in CSS pixels, or null. */ public function width(): ?int { return $this->width; } /** * Intrinsic image height in CSS pixels, or null. */ public function height(): ?int { return $this->height; } /** * §10.4 `loading` attribute value. */ public function loading(): string { return $this->loading; } /** * Report whether this view equals the supplied one. * * Equality is structural: every field must match with strict * comparison. * * @param self $other Other view to compare against. */ public function equals( self $other ): bool { return $this->url === $other->url && $this->alt === $other->alt && $this->width === $other->width && $this->height === $other->height && $this->loading === $other->loading; } /** * Validate and normalize a required string field at * construction. * * @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( 'ImageView %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 a nullable image-dimension field. * * Null passes through unchanged. A non-null value MUST be * strictly positive. * * @param string $field Field name for the error message. * @param int|null $value Raw value, or null. * * @throws InvalidArgumentException When `$value` is not * strictly positive. */ private static function normalizeOptionalDimension( string $field, ?int $value ): ?int { if ( $value === null ) { return null; } if ( $value < 1 ) { $message = sprintf( 'ImageView %s must be null or a strictly positive integer; got %d.', $field, $value ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $value; } /** * Validate and normalize the `loading` field at construction. * * The token is trimmed first so a carelessly-decoded payload * is normalized to the same canonical form a freshly- * constructed token would carry. The trimmed value MUST * appear in {@see self::VALID_LOADING}. * * @param string $loading Raw token as supplied by the caller. * * @throws InvalidArgumentException When `$loading` is not a * member of the closed set. */ private static function normalizeLoading( string $loading ): string { $trimmed = trim( $loading ); if ( ! in_array( $trimmed, self::VALID_LOADING, true ) ) { $message = sprintf( 'ImageView loading must be one of [%s]; got "%s".', implode( ', ', self::VALID_LOADING ), $loading ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } }