*/ private array $viewModel; /** * Rendered HTML string from * {@see \Affilun\Application\Render\ProductCardRenderer}. */ private string $html; /** * §10.1 preview-cache key string. */ private string $cacheKey; /** * Whether the §10.1 preview cache served this result. Phase-6 * minimum: always false (no preview cache layer wired yet). */ private bool $cacheHit; /** * Construct a preview result with explicit values for every * field. * * Validation: * * - `viewModel` array-shape is allowed empty. The field is * forwarded to the §10.1 envelope verbatim; a fallback * view model with no buttons can legitimately produce * an array whose top-level slots are present but empty. * - `html` is rejected when trimming leaves an empty * string — an upstream rendering failure must not * silently produce an empty preview card. * - `cacheKey` is rejected when trimming leaves an empty * string — a key-composer programming error must not * silently produce a malformed cache key. * * @param array $viewModel §8.2 / §8.4 view-model array. * @param string $html Rendered HTML string. * @param string $cacheKey §10.1 preview-cache key. * @param bool $cacheHit Whether the cache served this result. * * @throws InvalidArgumentException When `$html` or `$cacheKey` * is the empty string after * trimming. */ public function __construct( array $viewModel, string $html, string $cacheKey, bool $cacheHit ) { $html = self::sanitizeHtml( $html ); if ( trim( $html ) === '' ) { $message = 'PreviewResult html must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( trim( $cacheKey ) === '' ) { $message = 'PreviewResult cacheKey must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->viewModel = $viewModel; $this->html = $html; $this->cacheKey = $cacheKey; $this->cacheHit = $cacheHit; } /** * Sanitize preview HTML for safe insertion in the block editor. * * @param string $html Rendered preview HTML. */ private static function sanitizeHtml( string $html ): string { if ( function_exists( 'wp_kses_post' ) ) { return (string) wp_kses_post( $html ); } $sanitized = preg_replace( '#<(script|style)\\b[^>]*>.*?#is', '', $html ); if ( ! is_string( $sanitized ) ) { return ''; } $sanitized = preg_replace( '/\\s+on[a-z0-9_-]+\\s*=\\s*("[^"]*"|\'[^\']*\'|[^\\s>]+)/i', '', $sanitized ); if ( ! is_string( $sanitized ) ) { return ''; } $sanitized = preg_replace( '/\\s+(href|src)\\s*=\\s*("|\')\\s*javascript:[^"\']*\\2/i', '', $sanitized ); return is_string( $sanitized ) ? $sanitized : ''; } /** * §8.2 / §8.4 view-model array. * * @return array */ public function viewModel(): array { return $this->viewModel; } /** * Rendered HTML string. */ public function html(): string { return $this->html; } /** * §10.1 preview-cache key string. */ public function cacheKey(): string { return $this->cacheKey; } /** * Whether the §10.1 preview cache served this result. */ public function cacheHit(): bool { return $this->cacheHit; } /** * Project this VO onto the §10.1 `data` array shape the REST * controller wraps in the success envelope. * * The returned array's top-level key set and ordering match * `specifications/rest-api-spec.md` §10.1 verbatim: * * - `view_model` (snake_case at the wire boundary). * - `html`. * - `cache.key`, `cache.hit`. * * @return array{ * view_model: array, * html: string, * cache: array{key: string, hit: bool} * } */ public function toArray(): array { return array( 'view_model' => $this->viewModel, 'html' => $this->html, 'cache' => array( 'key' => $this->cacheKey, 'hit' => $this->cacheHit, ), ); } /** * Report whether this VO equals the supplied one. * * Equality is structural: the view-model array is compared * with strict `===` (key order and value types matter), the * scalar fields with strict comparison. * * @param self $other Other VO to compare against. */ public function equals( self $other ): bool { if ( $this->html !== $other->html ) { return false; } if ( $this->cacheKey !== $other->cacheKey ) { return false; } if ( $this->cacheHit !== $other->cacheHit ) { return false; } return $this->viewModel === $other->viewModel; } }