*/ private const VALID_REL_POLICIES = array( self::REL_POLICY_DEFAULT, self::REL_POLICY_MINIMAL, self::REL_POLICY_STRICT, ); /** * §11.2 canonical token order for the merged `rel` value. * * @var list */ private const REL_CANONICAL_ORDER = array( 'nofollow', 'sponsored', 'noopener', 'noreferrer', ); /** * Closed set of accepted §11.2 rel-policy tokens. * * @return list */ public static function validRelPolicies(): array { return self::VALID_REL_POLICIES; } /** * Build a §10.7 {@see ButtonView} from the supplied inputs. * * @param ProductSnapshot $snapshot Domain product snapshot. * @param AffiliateLinkResult $affiliate Affiliate-link builder output. * @param RenderContext $context Active render context. * @param ButtonConfigInput $config Button configuration input. * * @throws InvalidArgumentException When the selected URL is * not `http(s)://`, when the * context's rel policy is * unknown, or when any * ButtonView field violates * its own constructor * contract. */ public function build( ProductSnapshot $snapshot, AffiliateLinkResult $affiliate, RenderContext $context, ButtonConfigInput $config ): ButtonView { $url = $this->selectUrl( $snapshot, $affiliate, $config ); $target = $this->selectTarget( $affiliate, $context ); $rel = $this->mergeRel( $affiliate, $context, $target ); $className = $this->composeClassName( $snapshot->provider(), $config->presetClassName() ); $ariaLabel = $this->composeAriaLabel( $snapshot->title(), $config->providerLabel(), $context->locale() ); return new ButtonView( $snapshot->provider(), $config->label(), $url, $target, $rel, $className, $ariaLabel ); } /** * Select the §10.7 button URL per the §11 selection rule. * * @param ProductSnapshot $snapshot Domain product snapshot. * @param AffiliateLinkResult $affiliate Affiliate-link builder output. * @param ButtonConfigInput $config Button configuration input. * * @throws InvalidArgumentException When the chosen URL is not * `http(s)://`. */ private function selectUrl( ProductSnapshot $snapshot, AffiliateLinkResult $affiliate, ButtonConfigInput $config ): string { $candidate = ( $config->prefersTrackedUrl() && $affiliate->trackingApplied() ) ? $affiliate->url() : $snapshot->detailUrl(); if ( ! self::isHttpUrl( $candidate ) ) { $message = sprintf( 'LinkAttributeBuilder could not derive an http(s):// button URL; got "%s".', $candidate ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $candidate; } /** * Resolve the §11.3 `target` attribute per the priority chain * documented at the file level. * * @param AffiliateLinkResult $affiliate Affiliate-link builder output. * @param RenderContext $context Active render context. */ private function selectTarget( AffiliateLinkResult $affiliate, RenderContext $context ): ?string { if ( $context->isAmp() ) { return null; } if ( $context->isFeed() ) { return null; } $override = $context->linkTarget(); if ( $override !== null ) { return $override; } $affiliateTarget = $affiliate->target(); if ( $affiliateTarget !== null ) { return $affiliateTarget; } if ( $context->isFrontend() ) { return ButtonView::TARGET_BLANK; } return null; } /** * Merge the §11.2 `rel` token list and apply any * RenderContext-level policy override. * * @param AffiliateLinkResult $affiliate Affiliate-link builder output. * @param RenderContext $context Active render context. * @param string|null $target Resolved §11.3 `target` value. * * @throws InvalidArgumentException When the policy string is * not a member of * {@see self::VALID_REL_POLICIES}. */ private function mergeRel( AffiliateLinkResult $affiliate, RenderContext $context, ?string $target ): string { $policy = $context->linkRelPolicy() ?? self::REL_POLICY_DEFAULT; if ( ! in_array( $policy, self::VALID_REL_POLICIES, true ) ) { $message = sprintf( 'LinkAttributeBuilder rel policy must be one of [%s]; got "%s".', implode( ', ', self::VALID_REL_POLICIES ), $policy ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $policy === self::REL_POLICY_MINIMAL ) { return self::sortCanonical( array( 'nofollow', 'sponsored' ) ); } if ( $policy === self::REL_POLICY_STRICT ) { return self::sortCanonical( array( 'nofollow', 'sponsored', 'noopener', 'noreferrer' ) ); } $tokens = array(); foreach ( $affiliate->relAttributes() as $token ) { $tokens[] = strtolower( $token ); } $tokens[] = 'sponsored'; $tokens[] = 'nofollow'; if ( $target === ButtonView::TARGET_BLANK ) { $tokens[] = 'noopener'; $tokens[] = 'noreferrer'; } return self::sortCanonical( $tokens ); } /** * Compose the §10.7 `class` attribute string from the * canonical `affilun-button` / `affilun-button-{provider}` tokens * plus the caller-supplied preset class string. * * @param string $provider Lowercase provider key. * @param string|null $preset Caller-supplied preset class string, or null. */ private function composeClassName( string $provider, ?string $preset ): string { $tokens = array( 'affilun-button', 'affilun-button-' . $provider ); if ( $preset !== null ) { $parts = preg_split( '/\s+/', trim( $preset ) ); if ( is_array( $parts ) ) { foreach ( $parts as $part ) { if ( $part !== '' ) { $tokens[] = $part; } } } } $seen = array(); $result = array(); foreach ( $tokens as $token ) { if ( ! isset( $seen[ $token ] ) ) { $seen[ $token ] = true; $result[] = $token; } } return implode( ' ', $result ); } /** * Compose the §10.7 `aria-label` per the locale-driven * template selection. * * @param string $title Product title. * @param string $providerLabel Human-readable provider name. * @param string|null $locale BCP-47 locale tag, or null. */ private function composeAriaLabel( string $title, string $providerLabel, ?string $locale ): string { if ( self::isJapaneseLocale( $locale ) ) { return trim( sprintf( '%s を %s で見る', $title, $providerLabel ) ); } return trim( sprintf( 'View %s on %s', $title, $providerLabel ) ); } /** * Sort a token list into the §11.2 canonical order, dropping * duplicates case-insensitively. * * @param list $tokens Raw token list. */ private static function sortCanonical( array $tokens ): string { $lookup = array(); foreach ( $tokens as $token ) { $lookup[ strtolower( $token ) ] = true; } $ordered = array(); foreach ( self::REL_CANONICAL_ORDER as $canonical ) { if ( isset( $lookup[ $canonical ] ) ) { $ordered[] = $canonical; unset( $lookup[ $canonical ] ); } } // Preserve any non-canonical tokens (open-set per // AffiliateLinkResult §7.8) after the canonical four, // keeping their first-seen order. foreach ( array_keys( $lookup ) as $extra ) { $ordered[] = $extra; } return implode( ' ', $ordered ); } /** * Test whether a URL string starts with `http://` or * `https://`. * * @param string $url Raw URL string. */ private static function isHttpUrl( string $url ): bool { $trimmed = trim( $url ); if ( $trimmed === '' ) { return false; } return strpos( $trimmed, 'http://' ) === 0 || strpos( $trimmed, 'https://' ) === 0; } /** * Test whether the supplied locale tag selects the Japanese * aria-label template. * * Null and empty locale tags select Japanese (the plugin's * default audience); explicit `ja` / `ja-*` tags also * select Japanese; everything else selects English. * * @param string|null $locale BCP-47 locale tag, or null. */ private static function isJapaneseLocale( ?string $locale ): bool { if ( $locale === null ) { return true; } $normalized = strtolower( trim( $locale ) ); if ( $normalized === '' ) { return true; } if ( $normalized === 'ja' ) { return true; } return strpos( $normalized, 'ja-' ) === 0 || strpos( $normalized, 'ja_' ) === 0; } }