VO mapping lands in Phase 9; Phase 5 * intentionally does not model it. * * Phase 5 deliberately does not yet model the complete §15 * `EmbedConfig` shape: that type carries a much wider surface * (preset id, provider-specific copy overrides, schema * versioning, persisted block-attribute serialization, ...) than * the ViewModel factory needs. This DTO carries the minimal * closed set of fields the factory consumes today so the * factory's signature does not have to change when the full * EmbedConfig lands in Phase 9: * * - `template` is the §6.4 template key * ({@see \Affilun\Domain\Render\RenderContext::template()} * mirrors the same axis at the rendering surface). Required, * non-empty after trimming. * * - `buttonConfigs` is the ordered `list` the * §10.7 buttons-region renders. Empty list is a valid graceful- * degradation outcome per §13.3 ("ボタンなし: detail URL が * あれば最低限のリンクを生成可能") so callers that intentionally * suppress all buttons can pass `array()`. * * - `meta` is the §8.2 `meta` slot toggle bundle * ({@see MetaFlags}). Defaults to {@see MetaFlags::defaults()} * when callers want the canonical baseline; non-default * combinations are produced via the {@see MetaFlags} per-axis * wither helpers before being handed to this DTO. * * - `customRootClass` is the optional caller-supplied extra CSS * class string the §8.2 `classes.root` field appends after * the canonical `affilun-card affilun-card-{template} * affilun-provider-{provider}` tokens. Nullable because most call * sites do not need it; non-null values are trimmed and * rejected when the trim leaves an empty string — "present * but empty" is treated as a programming error. * * - `prefersFallback` is the explicit caller request to render * the §8.4 FallbackCardViewModel even when the snapshot * itself is healthy. Drives the admin "preview fallback" * toggle so block-editor users can validate the fallback * surface without having to break their data. Defaults to * false. * * Instances are immutable. There are no `with*()` mutation helpers * because an embed configuration is composed once per render at * the controller / shortcode boundary; in-place edits would only * invite ambiguity about which fields are authoritative. * * @package Affilun\Application\Render */ declare(strict_types=1); namespace Affilun\Application\Render; use Affilun\Application\Render\Builder\ButtonConfigInput; use Affilun\Application\Render\ViewModel\Parts\MetaFlags; use InvalidArgumentException; /** * Immutable Phase-5 embed configuration input bundle. * * See the file-level docblock for the rationale for carrying only * the minimal closed set of fields the §9.1 ViewModel factory * actually consumes today and the field-by-field validation rules. */ final class EmbedConfigInput { /** * §6.4 template key. */ private string $template; /** * Ordered list of button configuration inputs. * * @var list */ private array $buttonConfigs; /** * §8.2 `meta` slot toggle bundle. */ private MetaFlags $meta; /** * Caller-supplied extra CSS class string, or null. */ private ?string $customRootClass; /** * Whether the caller explicitly requests fallback rendering. */ private bool $prefersFallback; /** * Construct an embed configuration input with explicit values * for every field. * * Validation summary: * * - `template` is trimmed and rejected when empty after * trimming. * - `buttonConfigs` MUST be a `list` * (zero-based, contiguous int keys, every entry a * {@see ButtonConfigInput}); associative and mixed-type * arrays are rejected. * - `customRootClass`, when non-null, is trimmed and * rejected when the trim leaves an empty string. * * @param string $template §6.4 template key. * @param list $buttonConfigs Ordered button config list. * @param MetaFlags $meta §8.2 `meta` slot toggle bundle. * @param string|null $customRootClass Extra CSS class string, or null. * @param bool $prefersFallback Whether fallback rendering is requested. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( string $template, array $buttonConfigs, MetaFlags $meta, ?string $customRootClass = null, bool $prefersFallback = false ) { $this->template = self::normalizeTemplate( $template ); $this->buttonConfigs = self::normalizeButtonConfigs( $buttonConfigs ); $this->meta = $meta; $this->customRootClass = self::normalizeOptionalString( 'customRootClass', $customRootClass ); $this->prefersFallback = $prefersFallback; } /** * Construct an embed configuration input with the canonical * Phase-5 defaults: empty button list, default * {@see MetaFlags}, no custom root class, fallback not * requested. * * @param string $template §6.4 template key. Defaults to the * canonical `'single-default-card'` * Phase-5 baseline. * * @throws InvalidArgumentException When `$template` is empty * after trimming. */ public static function defaults( string $template = 'single-default-card' ): self { return new self( $template, array(), MetaFlags::defaults(), null, false ); } /** * §6.4 template key. */ public function template(): string { return $this->template; } /** * Ordered list of button configuration inputs. * * @return list */ public function buttonConfigs(): array { return $this->buttonConfigs; } /** * §8.2 `meta` slot toggle bundle. */ public function meta(): MetaFlags { return $this->meta; } /** * Caller-supplied extra CSS class string, or null. */ public function customRootClass(): ?string { return $this->customRootClass; } /** * Whether the caller explicitly requests fallback rendering. */ public function prefersFallback(): bool { return $this->prefersFallback; } /** * Report whether this input equals the supplied one. * * Equality is structural: scalar fields must match with * strict comparison, the {@see MetaFlags} slot must compare * equal under its own `equals()`, and the * {@see ButtonConfigInput} list must agree on length and * per-entry equality. * * @param self $other Other input to compare against. */ public function equals( self $other ): bool { if ( $this->template !== $other->template ) { return false; } if ( $this->customRootClass !== $other->customRootClass ) { return false; } if ( $this->prefersFallback !== $other->prefersFallback ) { return false; } if ( ! $this->meta->equals( $other->meta ) ) { return false; } if ( count( $this->buttonConfigs ) !== count( $other->buttonConfigs ) ) { return false; } foreach ( $this->buttonConfigs as $index => $config ) { if ( ! $config->equals( $other->buttonConfigs[ $index ] ) ) { return false; } } return true; } /** * Validate and normalize the `template` field at construction. * * @param string $template Raw template key as supplied by the caller. * * @throws InvalidArgumentException When `$template` is empty * after trimming. */ private static function normalizeTemplate( string $template ): string { $trimmed = trim( $template ); if ( $trimmed === '' ) { $message = 'EmbedConfigInput template must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `buttonConfigs` field at * construction. * * The array MUST be a `list` (zero-based, * contiguous int keys, every entry a * {@see ButtonConfigInput}); associative and mixed-type * arrays are rejected with a developer-facing message. * * @param array $buttonConfigs Raw array as supplied by the caller. * * @return list * * @throws InvalidArgumentException When `$buttonConfigs` is * not a list of * {@see ButtonConfigInput}. */ private static function normalizeButtonConfigs( array $buttonConfigs ): array { $expected = 0; $result = array(); foreach ( $buttonConfigs as $key => $value ) { if ( $key !== $expected ) { $message = 'EmbedConfigInput buttonConfigs must be a zero-based contiguous list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $value instanceof ButtonConfigInput ) { $message = 'EmbedConfigInput buttonConfigs entries must be ButtonConfigInput instances.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $result[] = $value; ++$expected; } return $result; } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * @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( 'EmbedConfigInput %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; } }