` 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;
}
}