` element without * re-deriving any of those fields from a sibling RenderContext or * ProviderCapabilities map at render time. * * Field semantics match `specifications/rendering-and-template-spec.md` * §10.7 exactly: * * - `provider` is the lowercase provider key the button targets * (`amazon`, `rakuten`, `yahoo_shopping`, ...). Required, * non-empty after trimming. The closed set lives at the * Provider registry, not on this DTO. * * - `label` is the §10.7 "ボタン属性 / label" pre-formatted * button label ("Amazon", "楽天市場で見る"). Required, * non-empty after trimming. * * - `url` is the §10.7 "ボタン属性 / url" target URL. Required, * non-empty after trimming, and MUST start with `http://` or * `https://` so a renderer cannot accidentally emit a * `javascript:` or `data:` URI. * * - `target` is the §10.7 "ボタン属性 / target" window-target * attribute. Nullable because the link-attribute builder may * defer the attribute on AMP / feed surfaces. When non-null, * it MUST be one of `'_blank'`, `'_self'`, `'_parent'`, or * `'_top'`. * * - `rel` is the §10.7 "ボタン属性 / rel" pre-tokenized * attribute value ("nofollow sponsored noopener noreferrer"). * Held verbatim because the link-attribute builder is * responsible for the policy-to-token mapping; this DTO only * verifies non-emptiness. * * - `className` is the §10.7 "ボタン属性 / class_name" * pre-tokenized BEM class string ("affilun-button * affilun-button-amazon"). Held verbatim, non-empty. * * - `ariaLabel` is the §10.7 "aria-label 例" pre-formatted * accessibility label ("Sample Product を Amazon で見る"). * Required, non-empty after trimming. * * Instances are immutable. There are no `with*()` mutation helpers * because a button 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 button-view used by §8.2 ProductCardViewModel and the * §10.7 button rendering rules. * * See the file-level docblock for the mapping to * `rendering-and-template-spec.md` §10.7 and the per-field * validation rules. */ final class ButtonView { /** * §10.7 `target="_blank"` token. */ public const TARGET_BLANK = '_blank'; /** * §10.7 `target="_self"` token. */ public const TARGET_SELF = '_self'; /** * §10.7 `target="_parent"` token. */ public const TARGET_PARENT = '_parent'; /** * §10.7 `target="_top"` token. */ public const TARGET_TOP = '_top'; /** * Closed set of accepted §10.7 target tokens, in declaration * order. * * @var list */ private const VALID_TARGETS = array( self::TARGET_BLANK, self::TARGET_SELF, self::TARGET_PARENT, self::TARGET_TOP, ); /** * Lowercase provider key the button targets. */ private string $provider; /** * Pre-formatted button label. */ private string $label; /** * Absolute target URL (`http://` or `https://`). */ private string $url; /** * One of the {@see self::TARGET_*} tokens, or null. */ private ?string $target; /** * Pre-tokenized `rel` attribute value. */ private string $rel; /** * Pre-tokenized BEM class string. */ private string $className; /** * Pre-formatted accessibility label. */ private string $ariaLabel; /** * Construct a button view with explicit values for every * field. * * Validation summary: * * - `provider`, `label`, `rel`, `className`, `ariaLabel` are * trimmed and rejected when empty after trimming. * - `url` is trimmed, rejected when empty, and rejected when * it does not start with `http://` or `https://`. * - `target`, when non-null, is trimmed and matched against * {@see self::VALID_TARGETS}. * * @param string $provider Lowercase provider key. * @param string $label Pre-formatted button label. * @param string $url Absolute target URL. * @param string|null $target Window-target attribute, or null. * @param string $rel Pre-tokenized `rel` value. * @param string $className Pre-tokenized BEM class string. * @param string $ariaLabel Pre-formatted aria-label. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( string $provider, string $label, string $url, ?string $target, string $rel, string $className, string $ariaLabel ) { $this->provider = self::normalizeRequiredString( 'provider', $provider ); $this->label = self::normalizeRequiredString( 'label', $label ); $this->url = self::normalizeUrl( $url ); $this->target = self::normalizeTarget( $target ); $this->rel = self::normalizeRequiredString( 'rel', $rel ); $this->className = self::normalizeRequiredString( 'className', $className ); $this->ariaLabel = self::normalizeRequiredString( 'ariaLabel', $ariaLabel ); } /** * Closed set of accepted §10.7 target tokens, in declaration * order. * * @return list */ public static function validTargets(): array { return self::VALID_TARGETS; } /** * Lowercase provider key the button targets. */ public function provider(): string { return $this->provider; } /** * Pre-formatted button label. */ public function label(): string { return $this->label; } /** * Absolute target URL. */ public function url(): string { return $this->url; } /** * Window-target attribute, or null. */ public function target(): ?string { return $this->target; } /** * Pre-tokenized `rel` attribute value. */ public function rel(): string { return $this->rel; } /** * Pre-tokenized BEM class string. */ public function className(): string { return $this->className; } /** * Pre-formatted accessibility label. */ public function ariaLabel(): string { return $this->ariaLabel; } /** * 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->provider === $other->provider && $this->label === $other->label && $this->url === $other->url && $this->target === $other->target && $this->rel === $other->rel && $this->className === $other->className && $this->ariaLabel === $other->ariaLabel; } /** * 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( 'ButtonView %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 the `url` field at construction. * * The string is trimmed first; an empty result is rejected. * The trimmed value MUST start with `http://` or `https://` * so a renderer cannot accidentally emit a `javascript:` or * `data:` URI. * * @param string $url Raw URL as supplied by the caller. * * @throws InvalidArgumentException When `$url` is empty after * trimming or does not start * with `http://` / `https://`. */ private static function normalizeUrl( string $url ): string { $trimmed = trim( $url ); if ( $trimmed === '' ) { $message = 'ButtonView url must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( strpos( $trimmed, 'http://' ) !== 0 && strpos( $trimmed, 'https://' ) !== 0 ) { $message = sprintf( 'ButtonView url must start with "http://" or "https://"; got "%s".', $url ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `target` field at construction. * * Null passes through unchanged. A non-null value is trimmed * and matched against {@see self::VALID_TARGETS}; out-of-set * tokens raise {@see InvalidArgumentException}. * * @param string|null $target Raw token, or null. * * @throws InvalidArgumentException When `$target` is not a * member of the closed set. */ private static function normalizeTarget( ?string $target ): ?string { if ( $target === null ) { return null; } $trimmed = trim( $target ); if ( ! in_array( $trimmed, self::VALID_TARGETS, true ) ) { $message = sprintf( 'ButtonView target must be null or one of [%s]; got "%s".', implode( ', ', self::VALID_TARGETS ), $target ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } }