` element * ("nofollow", "sponsored", "noopener", "noreferrer", ...). * Per §7.8 ("`relAttributes` は `nofollow`, `sponsored`, * `noopener`, `noreferrer` などを含みうる") the set is open- * ended; this DTO does NOT close the set because future * standards bodies may mint additional tokens and clamping at * this layer would force a re-release every time. The list is * normalized to a unique, lowercase, positionally-keyed list * so the rendering layer can `implode(' ', ...)` directly. * * - `target` is the `target` attribute value the rendering layer * SHOULD apply to the resulting `` element ("_blank" being * the canonical "open in a new tab" choice). Held as a plain * string at this layer; the closed set of HTML5 `target` * keywords is enforced by the rendering layer that knows the * surrounding markup. Nullable because not every consumer * wants to override the host theme's default link target. * * - `trackingApplied` is the explicit "did the builder actually * stamp tracking parameters onto the URL?" flag. Required. * Independent of `url` because a builder may legitimately * return the original detail URL unchanged when no tracking * context applies (no associate tag configured, the product * is from a provider whose program the site has not opted * into, ...); consuming surfaces use this flag to decide * whether to render an "affiliate link" disclosure badge. * * - `warnings` is the list of human-readable, administrator- * facing warning messages the builder accumulated while * producing the URL ("associate tag not configured", * "fallback to non-affiliate URL", ...). Per §7.8 * ("`warnings` は UI で管理者向けに表示可能") consuming * surfaces are expected to surface them in the admin UI * rather than to log them silently or to expose them to * end-users. The list is normalized to a positionally-keyed * list so JSON-encoding produces a JSON array. * * Instances are immutable. There are no `with*()` mutation helpers * because an affiliate-link result represents a single completed * builder pass: any change in the underlying tracking decision * should be modeled as a fresh builder call producing a fresh * {@see AffiliateLinkResult}, not as an in-place edit of an * existing one. * * @package Affilun\Domain\Affiliate */ declare(strict_types=1); namespace Affilun\Domain\Affiliate; use InvalidArgumentException; /** * Immutable normalized affiliate-link builder output. * * See the file-level docblock for the mapping to * `provider-api-contracts.md` §7.8, the open-set rationale for * `relAttributes`, and the independence of `trackingApplied` from * `url`. */ final class AffiliateLinkResult { /** * Affiliate-tracked URL per §7.8. Required and validated as a * `http(s)://` URL. */ private string $url; /** * `rel` attribute tokens the rendering layer SHOULD apply. * * Normalized to a unique, lowercase, positionally-keyed list * so callers can `implode(' ', ...)` directly. * * @var list */ private array $relAttributes; /** * `target` attribute value, or null when the rendering layer * should fall back to the host theme's default. */ private ?string $target; /** * Whether the builder actually stamped tracking parameters * onto the URL. */ private bool $trackingApplied; /** * Administrator-facing warning messages the builder * accumulated, in emission order. * * @var list */ private array $warnings; /** * Construct a result with explicit values for every field. * * The constructor is exhaustive so a programming error * (missing required field on a write, swapped url / target) * surfaces at the type system boundary rather than at the * rendering layer. * * Validation summary: * * - `url` is trimmed and validated as a `http(s)://` URL; * empty / malformed / non-http schemes raise * {@see InvalidArgumentException}. * - `relAttributes` entries are trimmed, lowercased, and * deduplicated. Non-string entries and entries that are * empty after trimming are rejected. * - `target`, when non-null, is trimmed and rejected when * the trim leaves an empty string. * - `warnings` entries are trimmed and rejected when not a * string or when empty after trimming. * * @param string $url Affiliate-tracked URL. * @param list $relAttributes `rel` attribute tokens. * @param string|null $target `target` attribute value, or null. * @param bool $trackingApplied Whether tracking was applied. * @param list $warnings Administrator-facing warnings. * * @throws InvalidArgumentException When any field violates the * §7.8 contract or the * cross-field validation rules * summarized above. */ public function __construct( string $url, array $relAttributes, ?string $target, bool $trackingApplied, array $warnings ) { $this->url = self::normalizeUrl( $url ); $this->relAttributes = self::normalizeRelAttributes( $relAttributes ); $this->target = self::normalizeOptionalString( 'target', $target ); $this->trackingApplied = $trackingApplied; $this->warnings = self::normalizeWarnings( $warnings ); } /** * Construct a "passthrough" result for the case where no * affiliate tracking applies and the builder is forwarding the * provider's own detail URL unchanged. * * Convenience factory for the common "no tracking configured / * fallback to plain detail link" path so call sites do not have * to spell out the empty `relAttributes`, the null `target`, * the explicit `trackingApplied = false`, and the empty * warnings list every time. * * @param string $url Plain detail URL to forward. * * @throws InvalidArgumentException When `$url` fails the URL * validator on the primary * constructor. */ public static function passthrough( string $url ): self { return new self( $url, array(), null, false, array() ); } /** * Affiliate-tracked URL per §7.8. */ public function url(): string { return $this->url; } /** * `rel` attribute tokens the rendering layer SHOULD apply, * normalized to a unique lowercase list. * * @return list */ public function relAttributes(): array { return $this->relAttributes; } /** * `target` attribute value, or null. */ public function target(): ?string { return $this->target; } /** * Whether the builder actually stamped tracking parameters * onto the URL. */ public function trackingApplied(): bool { return $this->trackingApplied; } /** * Administrator-facing warning messages, in emission order. * * @return list */ public function warnings(): array { return $this->warnings; } /** * Report whether this result carries any administrator-facing * warnings. * * Convenience guard so call sites can branch on "is there * anything to surface in the admin UI?" without manually * counting the list. */ public function hasWarnings(): bool { return $this->warnings !== array(); } /** * Render the `relAttributes` list as a single space-separated * string suitable for the HTML `rel` attribute, or null when * the list is empty. * * Returning null (rather than the empty string) when the list * is empty lets rendering layers conditionally emit the * attribute via `?:` without producing `rel=""` markup. */ public function relAttributesAsString(): ?string { if ( $this->relAttributes === array() ) { return null; } return implode( ' ', $this->relAttributes ); } /** * Validate and normalize the `url` field at construction. * * Trims surrounding whitespace, rejects the empty string, and * verifies the result is a syntactically-valid `http(s)://` * URL. The scheme guard is intentionally restrictive: the §7.8 * "`url` は必須" rule is meant to produce a clickable link, and * `data:` / `javascript:` / `ftp://` schemes are never that * even when they happen to satisfy `filter_var()`. * * @param string $url Raw URL. * * @throws InvalidArgumentException When `$url` is empty after * trimming, fails URL * validation, or uses a * non-http scheme. */ private static function normalizeUrl( string $url ): string { $trimmed = trim( $url ); if ( $trimmed === '' ) { $message = 'AffiliateLinkResult url must be a non-empty URL.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( filter_var( $trimmed, FILTER_VALIDATE_URL ) === false ) { $message = sprintf( 'AffiliateLinkResult url must be a valid http(s):// URL; got "%s".', $url ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Domain layer is WordPress-independent per coding-standards.md §1.3 B; wp_parse_url() would introduce a forbidden dependency. $scheme = strtolower( (string) parse_url( $trimmed, PHP_URL_SCHEME ) ); if ( $scheme !== 'http' && $scheme !== 'https' ) { $message = sprintf( 'AffiliateLinkResult url must use the http or https scheme; 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 `relAttributes` list. * * Each entry is trimmed and lowercased; non-string entries * raise {@see InvalidArgumentException}, as do entries that * trim to the empty string. The resulting list is deduplicated * (last occurrence wins) and re-keyed to a positional list so * callers always see a `list<>` shape. * * The set of accepted token values is deliberately open-ended: * §7.8 names `nofollow`, `sponsored`, `noopener`, `noreferrer` * as examples ("などを含みうる") rather than as a closed set, * and HTML5 `rel` token grammar is itself extensible. Closing * the set at this layer would force a re-release every time a * new token is standardized. * * @param array $relAttributes Raw list. * * @return list * * @throws InvalidArgumentException When any entry is not a * string or is empty after * trimming. */ private static function normalizeRelAttributes( array $relAttributes ): array { $normalized = array(); foreach ( $relAttributes as $index => $entry ) { if ( ! is_string( $entry ) ) { $message = sprintf( 'AffiliateLinkResult relAttributes[%s] must be a string; got %s.', (string) $index, gettype( $entry ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmed = strtolower( trim( $entry ) ); if ( $trimmed === '' ) { $message = sprintf( 'AffiliateLinkResult relAttributes[%s] must be a non-empty string.', (string) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! in_array( $trimmed, $normalized, true ) ) { $normalized[] = $trimmed; } } return $normalized; } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * Used for the `target` field where "present but empty" is a * programming error rather than a meaningful state — the host * theme's default applies when the field is null, so * `target=""` markup is never the intended output. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @return string|null Trimmed 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( 'AffiliateLinkResult %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; } /** * Validate and normalize the `warnings` list. * * Each entry is trimmed; non-string entries raise * {@see InvalidArgumentException}, as do entries that trim to * the empty string. Order is preserved (warnings are emitted * in chronological order during the builder pass) and the * resulting list is positionally keyed so JSON-encoding * produces a JSON array. * * Unlike `relAttributes`, warnings are NOT deduplicated: the * same warning may legitimately fire twice during a single * builder pass (for example, "fallback to non-affiliate URL" * emitted for both the primary and an alternative URL the * builder considered) and consuming surfaces are entitled to * see both occurrences. * * @param array $warnings Raw warnings list. * * @return list * * @throws InvalidArgumentException When any entry is not a * string or is empty after * trimming. */ private static function normalizeWarnings( array $warnings ): array { $normalized = array(); foreach ( $warnings as $index => $warning ) { if ( ! is_string( $warning ) ) { $message = sprintf( 'AffiliateLinkResult warnings[%s] must be a string; got %s.', (string) $index, gettype( $warning ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmed = trim( $warning ); if ( $trimmed === '' ) { $message = sprintf( 'AffiliateLinkResult warnings[%s] must be a non-empty string.', (string) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = $trimmed; } return $normalized; } }