` of disclaimer messages a §8.2 * ProductCardViewModel surfaces under its `disclaimers` slot. The * mapping follows `specifications/rendering-and-template-spec.md` * §10.8 (price disclaimer obligation), §13.3 (graceful * degradation: missing-data branches MUST NOT raise), and §23 * (administrator vs. end-user-facing messaging boundary). * * Ordering and content rules: * * - When a {@see PriceInfo} is supplied and reports * `disclaimerRequired() === true`, a price-disclaimer line is * prepended. The provider-supplied * {@see PriceInfo::disclaimerText()} is preferred verbatim; * when null, a AFFILUN-canonical message is selected by locale: * * * `ja-*` (and the unspecified-locale default) → * "価格および在庫情報は取得時点の情報です。" * * any non-`ja-*` locale → * "Price and availability are accurate as of the * observation time." * * - When the {@see RenderContext::trackingEnabled()} flag is * true (i.e. the surface actually emits affiliate-tracked * URLs), an affiliate-disclosure line is appended after the * price line so the order on screen reads * [price disclosure, sponsored-link disclosure]: * * * `ja-*` → "※本リンクはアフィリエイトです。Created by affilun.st" * * non-`ja-*` → "This page contains affiliate links. Created by affilun.st" * * - Entries are deduplicated case-sensitively (preserving * first-seen order) so a caller that supplies a price * disclaimer text identical to the sponsored-link disclosure * does not surface the same line twice. * * - Each entry is trimmed; entries that become empty after * trimming are silently skipped per §13.3. * * - The result is always a `list` (sequential int keys * starting at 0). The empty list is a valid outcome (no price * disclaimer required, tracking disabled). * * Instances carry no state and the {@see self::build()} method is * pure: same input, same output, no I/O, no globals. * * @package Affilun\Application\Render\Builder */ declare(strict_types=1); namespace Affilun\Application\Render\Builder; use Affilun\Domain\Pricing\PriceInfo; use Affilun\Domain\Product\ProductSnapshot; use Affilun\Domain\Render\RenderContext; /** * Pure §10.8 / §23 disclaimer-line builder. * * See the file-level docblock for the mapping to * `rendering-and-template-spec.md` §10.8 / §13.3 / §23 and the * locale-driven canonical-message selection. */ final class DisclaimerBuilder { /** * §10.8 AFFILUN-canonical price disclaimer for Japanese locales. */ private const PRICE_FALLBACK_JA = '価格および在庫情報は取得時点の情報です。'; /** * §10.8 AFFILUN-canonical price disclaimer for non-Japanese * locales. */ private const PRICE_FALLBACK_EN = 'Price and availability are accurate as of the observation time.'; /** * §23 sponsored-link disclosure for Japanese locales. */ private const SPONSORED_JA = '※本リンクはアフィリエイトです。Created by affilun.st'; /** * §23 sponsored-link disclosure for non-Japanese locales. */ private const SPONSORED_EN = 'This page contains affiliate links. Created by affilun.st'; /** * Build the §8.2 `disclaimers` slot value for a single card. * * @param ProductSnapshot $snapshot Domain product snapshot. * @param PriceInfo|null $price Price snapshot, or null. * @param RenderContext $context Active render context. * * @return list * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function build( ProductSnapshot $snapshot, ?PriceInfo $price, RenderContext $context ): array { unset( $snapshot ); $lines = array(); if ( $price !== null && $price->disclaimerRequired() ) { $provided = $price->disclaimerText(); if ( $provided !== null ) { $lines[] = $provided; } else { $lines[] = self::isJapaneseLocale( $context->locale() ) ? self::PRICE_FALLBACK_JA : self::PRICE_FALLBACK_EN; } } if ( $context->trackingEnabled() ) { $lines[] = self::isJapaneseLocale( $context->locale() ) ? self::SPONSORED_JA : self::SPONSORED_EN; } $seen = array(); $result = array(); foreach ( $lines as $line ) { $trimmed = trim( $line ); if ( $trimmed === '' ) { continue; } if ( isset( $seen[ $trimmed ] ) ) { continue; } $seen[ $trimmed ] = true; $result[] = $trimmed; } return $result; } /** * Test whether the supplied locale tag selects the Japanese * canonical-disclaimer template. * * Null and empty locale tags select Japanese (the plugin's * default audience); explicit `ja` / `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; } }