`
* root with the canonical BEM tokens; the wrapper-attribute
* wiring is deferred to a later slice that also reaches the
* §11.4 inspector-controls work.
*
* - **No locale plumbing.** Phase-7 minimum constructs the
* {@see RenderContext} with the site default locale (null);
* a later slice will derive the locale from the active
* post or the global locale filter chain.
*
* Render-callback signature:
*
* - WordPress invokes the callback as
* `render_callback($attributes, $content, $block)`. The
* {@see self::render()} method matches that signature so the
* callback is forward-compatible with WordPress's calling
* convention even though the Phase-7 minimum only consults
* `$attributes`.
*
* §11.4 invalid-state fallback policy:
*
* - When the caller is an administrator (the injected
* `currentUserCanProvider` returns true for `manage_options`),
* the renderer emits a `
`
* element carrying a localized human-readable reason so the
* misconfiguration is visible during authoring.
*
* - For non-administrators the renderer emits the empty string
* so the §11.4 "render nothing" posture is preserved on the
* public surface.
*
* - The reason text is always developer-facing Japanese
* copy (matching the §11.4 spec example phrasings); no
* internal exception details are surfaced.
*
* @package Affilun\Presentation\Block
*/
declare(strict_types=1);
namespace Affilun\Presentation\Block;
use Affilun\Application\Analytics\ClickToken;
use Affilun\Application\Cache\CacheKeyFactory;
use Affilun\Application\Cache\RenderCache;
use Affilun\Application\Product\SnapshotProjector;
use Affilun\Application\Affiliate\AffiliateLinkRouter;
use Affilun\Domain\Affiliate\AffiliateContext;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Render\EmbedConfigInput;
use Affilun\Application\Render\ProductCardRenderer;
use Affilun\Application\Render\ViewModel\Parts\BadgeView;
use Affilun\Application\Render\ViewModel\Parts\ButtonView;
use Affilun\Application\Render\ViewModel\Parts\MetaFlags;
use Affilun\Application\Render\ViewModel\ProductCardViewModel;
use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory;
use Affilun\Application\Render\ViewModelArrayDumper;
use Affilun\Application\Settings\OptionStoreSettingsRepository;
use Affilun\Application\Settings\RenderingDefaults;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Domain\Clock;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Render\RenderContext;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\ProductUrlRepository;
use Affilun\Infrastructure\Provider\Mercari\MercariProvider;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingAffiliateLinkBuilder;
use Affilun\Presentation\Card\ProductCardVisualSettings;
use Throwable;
/**
* Phase-7 slice 7b dynamic render callback host for the
* `affilun/product-card` block.
*
* See the file-level docblock for the §11.2 render flow, the
* Phase-7 minimum scope notes (no `buttonLabels`,
* `showDisclaimer` accepted-but-ignored, no
* `get_block_wrapper_attributes()` wrap), and the §11.4
* invalid-state fallback policy.
*/
final class ProductCardBlockRenderer {
/**
* §13.1 render-cache TTL (seconds) for the post-body adhoc
* Gutenberg path. Mirrors the §4 render-layer 60-second
* default; KI-007 wiring keeps this short so a snapshot
* change observed by KI-002's per-embed sweep is reflected
* even on the adhoc surface within at most one TTL window.
*/
private const RENDER_CACHE_TTL_SECONDS = 60;
/**
* `kind` discriminator passed to the §6.3
* {@see CacheKeyFactory::renderAdhocKey()} family for
* single-card renders.
*/
private const RENDER_CACHE_KIND = 'single';
/**
* `sortKey` passed to {@see CacheKeyFactory::productsHash()}
* for the single-card cache key. The single-card path always
* holds exactly one productId so sort order is irrelevant;
* `manual` is the canonical neutral choice.
*/
private const RENDER_CACHE_SORT_KEY = 'manual';
/**
* §6.4 grammar pattern (mirrored from
* {@see \Affilun\Application\Render\Template\TemplateKey})
* so a malformed `template` attribute can be silently demoted
* to {@see self::DEFAULT_TEMPLATE} without raising.
*/
private const TEMPLATE_PATTERN = '/\A[a-z][a-z0-9-]{1,63}\z/';
/**
* §11.4 admin-warning capability checked on every fallback.
*/
private const ADMIN_CAPABILITY = 'manage_options';
/**
* Persistence port for the §11.2 step-2 product lookup.
*/
private ProductRepository $repository;
/**
* Optional URL repository for provider-specific product pages.
*/
private ?ProductUrlRepository $productUrlRepository;
/**
* Optional settings repository used for provider button colors.
*/
private ?SettingsRepository $settingsRepository;
/**
* §9.1 view-model factory wired at construction.
*/
private ProductCardViewModelFactory $viewModelFactory;
/**
* §6.5 renderer wired at construction.
*/
private ProductCardRenderer $renderer;
/**
* §11.4 admin-vs-anonymous policy provider.
*
* Receives a capability slug and returns true when the active
* user holds it. The default forwards to
* {@see current_user_can()} when the WordPress global is
* loaded, otherwise denies (so pure-unit tests default to
* the anonymous branch).
*
* @var callable(string): bool
*/
private $currentUserCanProvider;
/**
* Current global rendering defaults loaded from settings.
*/
private RenderingDefaults $renderingDefaults;
/**
* Optional §4 render-cache layer wired at construction. When
* null, the renderer behaves exactly as the Phase 9 MVP and
* never reads or writes the cache (KI-007 fallback path).
*/
private ?RenderCache $renderCache;
/**
* Optional §6.3 cache key factory wired at construction.
* Required (alongside `$renderCache`) to enable the KI-007
* cache path; null disables it.
*/
private ?CacheKeyFactory $cacheKeys;
/**
* Optional provider registry used to build 0.4.0 multi-provider
* search buttons for enabled providers.
*/
private ?ProviderRegistry $providerRegistry;
/**
* Optional affiliate link router.
*/
private ?AffiliateLinkRouter $affiliateRouter;
/**
* Optional §8.4 view-model dumper wired at construction.
* Required to project the {@see ProductCardViewModel} into the
* canonical `view_model` payload stored on cache writes;
* without it the renderer can still satisfy cache reads but
* skips the cache write step.
*/
private ?ViewModelArrayDumper $dumper;
/**
* Construct a block renderer with explicit collaborators.
*
* @param ProductRepository $repository Persistence port.
* @param ProductCardViewModelFactory $viewModelFactory §9.1 factory.
* @param ProductCardRenderer $renderer §6.5 renderer.
* @param (callable(string): bool)|null $currentUserCanProvider §11.4 admin-policy provider.
* @param RenderCache|null $renderCache Optional §4 render-cache
* layer. When wired
* together with
* `$cacheKeys` and
* `$dumper`, the
* post-body adhoc
* render path reads
* from / writes to
* this layer (KI-007).
* When null, the
* renderer behaves
* exactly as the
* Phase 9 MVP — every
* request rebuilds
* the view model and
* re-renders the
* template.
* @param CacheKeyFactory|null $cacheKeys §6.3 key factory.
* Required to enable
* the cache path;
* ignored otherwise.
* @param ViewModelArrayDumper|null $dumper §8.4 dumper used to
* project the
* ProductCardViewModel
* into the canonical
* `view_model`
* payload stored on
* cache writes.
* @param ProviderRegistry|null $providerRegistry Optional enabled-provider registry
* used to append 0.4.0
* multi-provider search buttons.
* @param AffiliateLinkRouter|null $affiliateRouter Optional affiliate link router.
* @param ProductUrlRepository|null $productUrlRepository Optional provider URL repository.
* @param SettingsRepository|null $settingsRepository Optional settings repository.
* @param Clock $clock Render-time clock.
*/
public function __construct(
ProductRepository $repository,
ProductCardViewModelFactory $viewModelFactory,
ProductCardRenderer $renderer,
?callable $currentUserCanProvider = null,
?RenderCache $renderCache = null,
?CacheKeyFactory $cacheKeys = null,
?ViewModelArrayDumper $dumper = null,
?ProviderRegistry $providerRegistry = null,
?AffiliateLinkRouter $affiliateRouter = null,
?ProductUrlRepository $productUrlRepository = null,
?SettingsRepository $settingsRepository = null,
private Clock $clock = new SystemClock()
) {
$this->repository = $repository;
$this->viewModelFactory = $viewModelFactory;
$this->renderer = $renderer;
$this->currentUserCanProvider = $currentUserCanProvider
?? static fn ( string $cap ): bool => function_exists( 'current_user_can' )
? (bool) current_user_can( $cap )
: false;
$this->settingsRepository = $settingsRepository;
$this->renderingDefaults = $settingsRepository !== null
? self::loadRenderingDefaultsFromRepository( $settingsRepository )
: self::loadRenderingDefaults();
$this->renderCache = $renderCache;
$this->cacheKeys = $cacheKeys;
$this->dumper = $dumper;
$this->providerRegistry = $providerRegistry;
$this->affiliateRouter = $affiliateRouter;
$this->productUrlRepository = $productUrlRepository;
}
/**
* `render_callback` entry point for the `affilun/product-card`
* block.
*
* Signature matches WordPress's calling convention
* `render_callback($attributes, $content, $block)`. The
* Phase-7 minimum only consults `$attributes`; the second and
* third arguments are accepted for forward compatibility but
* unused.
*
* @param array
$attributes Block attributes.
* @param string $content Inner block content (unused).
* @param mixed $block WP_Block instance (unused).
*/
public function render( array $attributes, string $content = '', mixed $block = null ): string {
unset( $content, $block );
$productId = $this->resolveProductId( $attributes );
if ( $productId === null ) {
return $this->renderFallback(
'affilun: 商品が選択されていません。'
);
}
try {
$record = $this->repository->findById( $productId );
} catch ( Throwable $unused ) {
return $this->renderFallback(
'affilun: レンダリング中にエラーが発生しました。'
);
}
if ( $record === null ) {
return $this->renderFallback(
sprintf(
'affilun: 指定された商品が見つかりません (id: %d)。',
$productId
)
);
}
try {
return $this->renderInternal( $record, $attributes );
} catch ( Throwable $unused ) {
return $this->renderFallback(
'affilun: レンダリング中にエラーが発生しました。'
);
}
}
/**
* Drive the §11.2 happy-path render.
*
* @param ProductRecord $record Resolved persisted record.
* @param array $attributes Block attributes.
*/
private function renderInternal( ProductRecord $record, array $attributes ): string {
$template = $this->resolveTemplate( $attributes );
$customCssClass = $this->composeCustomRootClass( $attributes );
$showBrand = $this->resolveBoolAttribute( $attributes, 'showBrand', true );
$showDescription = $this->resolveBoolAttribute( $attributes, 'showDescription', false );
$showPrice = $this->resolveBoolAttribute( $attributes, 'showPrice', true );
$showProvider = $this->resolveBoolAttribute( $attributes, 'showProviderBadge', false );
// KI-007: try the §4 render cache layer when fully wired.
$cacheKey = $this->resolveCacheKey( $record, $this->renderCacheAttributes( $attributes ), $template );
if ( $cacheKey !== null && $this->renderCache !== null ) {
$hit = $this->renderCache->get( $cacheKey );
if ( $hit !== null && isset( $hit['html'] ) && is_string( $hit['html'] ) ) {
$html = $this->appendProviderButtonColorStyles( $hit['html'] );
$html = $this->appendPersistedProductIdAttribute( $html, $record->id() );
return $this->appendClickTokenAttributes( $html, $record->id() );
}
}
$snapshot = self::buildSnapshotFromRecord( $record );
$snapshot = $this->applyProviderAffiliateLink( $snapshot, $attributes );
$meta = MetaFlags::defaults()
->withShowBrand( $showBrand )
->withShowDescription( $showDescription )
->withShowPrice( $showPrice )
->withShowProviderBadge( $showProvider );
$config = new EmbedConfigInput(
$template,
array(),
$meta,
$customCssClass,
false
);
$context = RenderContext::frontend( $template );
$viewModel = $this->viewModelFactory->build( $snapshot, $config, $context, array() );
if ( $viewModel instanceof ProductCardViewModel ) {
$viewModel = $this->appendProviderSearchButtons(
$viewModel,
$snapshot,
$record->searchKeywords(),
$attributes,
$this->loadProviderUrls( $record->id() )
);
$viewModel = $this->appendCustomButtons( $viewModel, $snapshot, $attributes );
$viewModel = $this->appendMicrocopyBadge( $viewModel, $attributes );
}
$html = $this->renderer->render( $viewModel, $context );
$html = $this->appendProviderButtonColorStyles( $html );
$html = $this->appendPersistedProductIdAttribute( $html, $record->id() );
$html = $this->appendAbTestingAttributes( $html, $attributes, $record->id() );
if ( $cacheKey !== null && $this->renderCache !== null && $this->dumper !== null ) {
try {
$this->renderCache->put(
$cacheKey,
$this->dumper->dump( $viewModel ),
$html,
$this->renderCacheTtl( $attributes )
);
} catch ( Throwable $unused ) {
// Cache writes are best-effort: a failure must
// never break the render path. Swallowed.
unset( $unused );
}
}
return $this->appendClickTokenAttributes( $html, $record->id() );
}
/**
* Stamp the persisted product id onto the card root for click tracking.
*
* @param string $html Rendered card HTML.
* @param int|null $productId Persisted product id.
*/
private function appendPersistedProductIdAttribute( string $html, ?int $productId ): string {
if ( $productId === null || $productId <= 0 ) {
return $html;
}
$result = preg_replace_callback(
'/<([a-z][a-z0-9-]*)\b([^>]*\bclass=("|\')[^"\']*\baffilun-card\b[^"\']*\3[^>]*)>/i',
static function ( array $matches ) use ( $productId ): string {
$tag = (string) $matches[1];
$attrs = (string) $matches[2];
$value = 'data-affilun-product-id="' . self::escapeAttribute( (string) $productId ) . '"';
if ( preg_match( '/\sdata-affilun-product-id=("|\')[^"\']*\1/i', $attrs ) === 1 ) {
$attrs = preg_replace( '/\sdata-affilun-product-id=("|\')[^"\']*\1/i', ' ' . $value, $attrs, 1 );
$attrs = is_string( $attrs ) ? $attrs : (string) $matches[2];
return '<' . $tag . $attrs . '>';
}
return '<' . $tag . $attrs . ' ' . $value . '>';
},
$html,
1
);
return is_string( $result ) ? $result : $html;
}
/**
* Apply the configured provider's affiliate link builder to the snapshot.
*
* @param ProductSnapshot $snapshot Persisted product snapshot.
* @param array $attributes Block attributes.
*/
private function applyProviderAffiliateLink( ProductSnapshot $snapshot, array $attributes ): ProductSnapshot {
if ( $this->providerRegistry === null ) {
return $snapshot;
}
if ( ! $this->resolveBoolAttribute( $attributes, 'trackingEnabled', true ) ) {
return $snapshot;
}
$provider = $this->providerRegistry->all()[ $snapshot->provider() ] ?? null;
if ( $provider === null ) {
return $snapshot;
}
try {
$affiliate = $provider->buildAffiliateLink( $snapshot, AffiliateContext::empty() );
if ( $affiliate->trackingApplied() ) {
return $snapshot->withAffiliateUrl( $affiliate->url() );
}
} catch ( Throwable $unused ) {
unset( $unused );
}
return $snapshot;
}
/**
* Append buttons for every enabled provider to the card view model.
*
* @param ProductCardViewModel $viewModel Source view model.
* @param ProductSnapshot $snapshot Persisted product snapshot.
* @param string|null $searchKeywords Persisted cross-provider search keywords.
* @param array $attributes Block attributes.
* @param array $providerUrls Stored provider URLs keyed by provider.
*/
private function appendProviderSearchButtons(
ProductCardViewModel $viewModel,
ProductSnapshot $snapshot,
?string $searchKeywords,
array $attributes,
array $providerUrls
): ProductCardViewModel {
if ( $this->providerRegistry === null ) {
return $viewModel;
}
$query = $searchKeywords !== null && trim( $searchKeywords ) !== ''
? trim( $searchKeywords )
: self::deriveSearchQuery( $snapshot );
$buttons = $viewModel->buttons();
foreach ( $this->providerRegistry->all() as $providerKey => $provider ) {
$label = $this->resolveProviderButtonLabel( $providerKey, $provider->label(), $attributes );
if ( array_key_exists( $providerKey, $providerUrls ) ) {
$url = $providerUrls[ $providerKey ];
} elseif ( $providerKey === $snapshot->provider() ) {
$url = $snapshot->affiliateUrl() ?? $snapshot->detailUrl();
if ( $provider instanceof MercariProvider ) {
$url = $provider->buildAffiliateUrl( $url );
}
} else {
$url = $provider instanceof MercariProvider
? $provider->buildSearchUrl( $query )
: self::buildProviderSearchUrl( $providerKey, $query );
}
if ( $url === null ) {
continue;
}
$url = $this->routeProviderUrl( $providerKey, $url, $snapshot, $attributes );
try {
$buttons[] = new ButtonView(
$providerKey,
$label,
$url,
ButtonView::TARGET_BLANK,
'nofollow sponsored noopener noreferrer',
'affilun-button affilun-button-' . $providerKey,
sprintf( '%s を %s で見る', $snapshot->title(), $label )
);
} catch ( Throwable $unused ) {
unset( $unused );
}
}
return $viewModel->withButtons( $buttons );
}
/**
* Load stored provider URLs keyed by provider.
*
* @param int|null $productId Persisted product id.
*
* @return array
*/
private function loadProviderUrls( ?int $productId ): array {
if ( $productId === null || $this->productUrlRepository === null ) {
return array();
}
try {
$rows = $this->productUrlRepository->findByProductId( $productId );
} catch ( Throwable $unused ) {
unset( $unused );
return array();
}
$urls = array();
foreach ( $rows as $row ) {
$provider = $row->providerType();
if ( ! array_key_exists( $provider, $urls ) ) {
$urls[ $provider ] = $row->url();
}
}
return $urls;
}
/**
* Route provider URL when tracking and routing are enabled.
*
* @param string $providerKey Provider key.
* @param string $url URL.
* @param ProductSnapshot $snapshot Product snapshot.
* @param array $attributes Block attributes.
*/
private function routeProviderUrl(
string $providerKey,
string $url,
ProductSnapshot $snapshot,
array $attributes
): string {
if ( ! $this->resolveBoolAttribute( $attributes, 'trackingEnabled', true ) ) {
return $url;
}
if ( $providerKey === 'yahoo_shopping' && YahooShoppingAffiliateLinkBuilder::isValueCommerceRedirectUrl( $url ) ) {
return $url;
}
if ( $providerKey === 'yahoo_shopping' ) {
$trackedYahooUrl = $this->buildYahooValueCommerceUrl( $url );
if ( $trackedYahooUrl !== null ) {
return $trackedYahooUrl;
}
}
if ( $this->affiliateRouter === null ) {
return $url;
}
unset( $snapshot );
return $this->affiliateRouter->route( $providerKey, $url, null );
}
/**
* Build a ValueCommerce-tracked Yahoo URL from a stored provider URL.
*
* @param string $url Stored Yahoo destination URL.
*/
private function buildYahooValueCommerceUrl( string $url ): ?string {
if ( $this->settingsRepository === null || ! $this->isSafeHttpUrl( $url ) ) {
return null;
}
try {
$credentials = $this->settingsRepository->load()->provider( 'yahoo_shopping' );
} catch ( Throwable $unused ) {
unset( $unused );
return null;
}
if ( $credentials === null ) {
return null;
}
$sid = trim( (string) $credentials->secret( 'affiliate_sid' ) );
$pid = trim( (string) $credentials->secret( 'affiliate_pid' ) );
if ( $sid === '' || $pid === '' ) {
return null;
}
return YahooShoppingAffiliateLinkBuilder::buildReferralBaseUrl( $sid, $pid ) . rawurlencode( $url );
}
/**
* Resolve provider button label override.
*
* @param string $providerKey Provider key.
* @param string $defaultLabel Default provider label.
* @param array $attributes Block attributes.
*/
private function resolveProviderButtonLabel(
string $providerKey,
string $defaultLabel,
array $attributes
): string {
if ( ! isset( $attributes['buttonLabels'] ) || ! is_array( $attributes['buttonLabels'] ) ) {
return $defaultLabel;
}
$raw = $attributes['buttonLabels'][ $providerKey ] ?? null;
if ( ! is_string( $raw ) || trim( $raw ) === '' ) {
return $defaultLabel;
}
return $this->truncateAttributeText( trim( $raw ), 40 );
}
/**
* Append user-defined custom buttons to the card view model.
*
* @param ProductCardViewModel $viewModel Source view model.
* @param ProductSnapshot $snapshot Persisted product snapshot.
* @param array $attributes Block attributes.
*/
private function appendCustomButtons(
ProductCardViewModel $viewModel,
ProductSnapshot $snapshot,
array $attributes
): ProductCardViewModel {
$customButtons = $this->resolveCustomButtons( $attributes );
if ( $customButtons === array() ) {
return $viewModel;
}
$buttons = $viewModel->buttons();
foreach ( $customButtons as $button ) {
try {
$buttons[] = new ButtonView(
'custom',
$button['label'],
$button['url'],
$button['target'],
$button['rel'],
'affilun-button affilun-button-custom',
sprintf( '%s を %s で見る', $snapshot->title(), $button['label'] )
);
} catch ( Throwable $unused ) {
unset( $unused );
}
}
return $viewModel->withButtons( $buttons );
}
/**
* Add provider button color CSS variables to the rendered card root.
*
* Settings-page previews use the same variables; front-end renders need
* them stamped on the card root because provider colors live in settings,
* not in per-block attributes.
*
* @param string $html Rendered card HTML.
*/
private function appendProviderButtonColorStyles( string $html ): string {
$style = $this->providerButtonColorStyle();
if ( $style === '' ) {
return $html;
}
$result = preg_replace_callback(
'/<([a-z][a-z0-9-]*)\b([^>]*\bclass=("|\')[^"\']*\baffilun-card\b[^"\']*\3[^>]*)>/i',
static function ( array $matches ) use ( $style ): string {
$tag = (string) $matches[1];
$attrs = (string) $matches[2];
if ( preg_match( '/\sstyle=("|\')([^"\']*)\1/i', $attrs, $styleMatch ) === 1 ) {
$quote = (string) $styleMatch[1];
$currentStyle = rtrim( self::stripProviderButtonColorStyles( (string) $styleMatch[2] ) );
$mergedStyle = $currentStyle === '' ? $style : rtrim( $currentStyle, ';' ) . ';' . $style;
$styleAttr = ' style=' . $quote . self::escapeAttribute( $mergedStyle ) . $quote;
$attrs = preg_replace( '/\sstyle=("|\')[^"\']*\1/i', $styleAttr, $attrs, 1 );
$attrs = is_string( $attrs ) ? $attrs : (string) $matches[2];
return '<' . $tag . $attrs . '>';
}
return '<' . $tag . $attrs . ' style="' . self::escapeAttribute( $style ) . '">';
},
$html,
1
);
return is_string( $result ) ? $result : $html;
}
/**
* Build inline CSS custom properties for configured provider button colors.
*/
private function providerButtonColorStyle(): string {
if ( $this->settingsRepository === null ) {
return '';
}
try {
$settings = $this->settingsRepository->load();
} catch ( Throwable $unused ) {
unset( $unused );
return '';
}
return ProductCardVisualSettings::providerButtonColorStyle( $settings );
}
/**
* Remove previously injected provider button color variables.
*
* @param string $style Inline style declaration.
*/
private static function stripProviderButtonColorStyles( string $style ): string {
$cleaned = preg_replace(
'/--affilun-color-(?:amazon|rakuten|yahoo|mercari)(?:-text)?:[^;]*;?/',
'',
$style
);
return trim( is_string( $cleaned ) ? $cleaned : $style );
}
/**
* Resolve custom button attribute payload.
*
* @param array $attributes Block attributes.
*
* @return list
*/
private function resolveCustomButtons( array $attributes ): array {
if ( ! isset( $attributes['customButtons'] ) || ! is_array( $attributes['customButtons'] ) ) {
return array();
}
$result = array();
foreach ( $attributes['customButtons'] as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
$label = isset( $entry['label'] ) && is_string( $entry['label'] ) ? trim( $entry['label'] ) : '';
$url = isset( $entry['url'] ) && is_string( $entry['url'] ) ? trim( $entry['url'] ) : '';
$target = $this->resolveCustomButtonTarget( $entry );
$rel = $this->resolveCustomButtonRel( $entry, $target );
if ( $label === '' || ! $this->isSafeHttpUrl( $url ) ) {
continue;
}
$result[] = array(
'label' => $this->truncateAttributeText( $label, 80 ),
'url' => $url,
'target' => $target,
'rel' => $rel,
);
}
return $result;
}
/**
* Resolve a custom button target attribute.
*
* @param array $entry Custom button payload.
*/
private function resolveCustomButtonTarget( array $entry ): string {
if ( ! isset( $entry['target'] ) || ! is_string( $entry['target'] ) ) {
return ButtonView::TARGET_BLANK;
}
$target = trim( $entry['target'] );
return in_array( $target, ButtonView::validTargets(), true ) ? $target : ButtonView::TARGET_BLANK;
}
/**
* Resolve and sanitize custom button rel tokens.
*
* @param array $entry Custom button payload.
* @param string $target Resolved target.
*/
private function resolveCustomButtonRel( array $entry, string $target ): string {
$raw = isset( $entry['rel'] ) && is_string( $entry['rel'] ) ? $entry['rel'] : 'nofollow';
$tokens = preg_split( '/\s+/', strtolower( trim( $raw ) ) );
$result = array();
if ( is_array( $tokens ) ) {
foreach ( $tokens as $token ) {
if ( preg_match( '/\A[a-z0-9_-]+\z/', $token ) === 1 ) {
$result[] = $token;
}
}
}
if ( $result === array() ) {
$result[] = 'nofollow';
}
if ( $target === ButtonView::TARGET_BLANK ) {
$result[] = 'noopener';
$result[] = 'noreferrer';
}
return implode( ' ', array_values( array_unique( $result ) ) );
}
/**
* Check whether a URL is an absolute HTTP(S) URL.
*
* @param string $url URL candidate.
*/
private function isSafeHttpUrl( string $url ): bool {
return preg_match( '#\Ahttps?://#i', $url ) === 1;
}
/**
* Truncate attribute text without requiring mbstring.
*
* @param string $value Raw text.
* @param int $length Maximum length.
*/
private function truncateAttributeText( string $value, int $length ): string {
if ( function_exists( 'mb_substr' ) ) {
return mb_substr( $value, 0, $length, 'UTF-8' );
}
return substr( $value, 0, $length );
}
/**
* Append an active manual microcopy / sale label badge.
*
* @param ProductCardViewModel $viewModel Source view model.
* @param array $attributes Block attributes.
*/
private function appendMicrocopyBadge( ProductCardViewModel $viewModel, array $attributes ): ProductCardViewModel {
$label = $this->resolveActiveMicrocopyLabel( $attributes );
if ( $label === null ) {
return $viewModel;
}
$badges = $viewModel->badges();
$badges[] = new BadgeView( BadgeView::KIND_SALE, $label, 'affilun-badge affilun-badge-sale' );
return $viewModel->withBadges( $badges );
}
/**
* Append URL-scoped click-token metadata to rendered button anchors.
*
* @param string $html Rendered HTML.
* @param int|null $productId Persisted product id.
*/
private function appendClickTokenAttributes( string $html, ?int $productId ): string {
if ( $productId === null || $productId <= 0 ) {
return $html;
}
$postId = self::currentPostId();
$issuedAt = $this->clock->now()->getTimestamp();
$result = preg_replace_callback(
'/]*)>/i',
static function ( array $matches ) use ( $productId, $postId, $issuedAt ): string {
$attrs = (string) $matches[1];
if ( stripos( $attrs, 'affilun-button' ) === false || stripos( $attrs, 'data-affilun-click-token' ) !== false ) {
return $matches[0];
}
if ( preg_match( '/\shref\s*=\s*("|\')([^"\']+)\1/i', $attrs, $hrefMatch ) !== 1 ) {
return $matches[0];
}
$targetUrl = html_entity_decode( $hrefMatch[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$token = ClickToken::issue( $productId, $issuedAt, $targetUrl, $postId );
return '';
},
$html
);
return is_string( $result ) ? $result : $html;
}
/**
* Current WordPress post id when available.
*/
private static function currentPostId(): ?int {
if ( function_exists( 'get_the_ID' ) ) {
$postId = (int) get_the_ID();
return $postId > 0 ? $postId : null;
}
return null;
}
/**
* Append front-end A/B testing metadata to the rendered root element.
*
* @param string $html Rendered HTML.
* @param array $attributes Block attributes.
* @param int|null $productId Persisted product id.
*/
private function appendAbTestingAttributes( string $html, array $attributes, ?int $productId ): string {
if ( ! $this->resolveBoolAttribute( $attributes, 'abTestingEnabled', false ) ) {
return $html;
}
$buttonLabel = $this->resolveAbTestingText( $attributes, 'abButtonLabelVariant', 40 );
$microcopy = $this->resolveAbTestingText( $attributes, 'abMicrocopyVariant', 80 );
if ( $buttonLabel === null && $microcopy === null ) {
return $html;
}
$attrs = ' data-affilun-ab-enabled="1"';
if ( $productId !== null && $productId > 0 ) {
$attrs .= ' data-affilun-ab-key="' . self::escapeAttribute( (string) $productId ) . '"';
}
if ( $buttonLabel !== null ) {
$attrs .= ' data-affilun-ab-button-label-b="' . self::escapeAttribute( $buttonLabel ) . '"';
}
if ( $microcopy !== null ) {
$attrs .= ' data-affilun-ab-microcopy-b="' . self::escapeAttribute( $microcopy ) . '"';
}
$result = preg_replace_callback(
'/<([a-z][a-z0-9-]*)\b/i',
static fn ( array $matches ): string => '<' . $matches[1] . $attrs,
$html,
1
);
return is_string( $result ) ? $result : $html;
}
/**
* Resolve A/B testing text attribute.
*
* @param array $attributes Block attributes.
* @param string $key Attribute key.
* @param int $maxLength Maximum length.
*/
private function resolveAbTestingText( array $attributes, string $key, int $maxLength ): ?string {
if ( ! isset( $attributes[ $key ] ) || ! is_string( $attributes[ $key ] ) ) {
return null;
}
$value = trim( $attributes[ $key ] );
if ( $value === '' ) {
return null;
}
return $this->truncateAttributeText( $value, $maxLength );
}
/**
* Escape an HTML attribute without depending on WordPress globals.
*
* @param string $value Attribute value.
*/
private static function escapeAttribute( string $value ): string {
return htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
}
/**
* Resolve active microcopy label when the display window includes now.
*
* @param array $attributes Block attributes.
*/
private function resolveActiveMicrocopyLabel( array $attributes ): ?string {
if ( ! isset( $attributes['microcopyText'] ) || ! is_string( $attributes['microcopyText'] ) ) {
return null;
}
$label = trim( $attributes['microcopyText'] );
if ( $label === '' || ! $this->isWithinMicrocopyWindow( $attributes ) ) {
return null;
}
return $this->truncateAttributeText( $label, 80 );
}
/**
* Check optional microcopy start/end dates.
*
* @param array $attributes Block attributes.
*/
private function isWithinMicrocopyWindow( array $attributes ): bool {
$now = $this->clock->now()->getTimestamp();
$start = isset( $attributes['microcopyStartAt'] ) && is_string( $attributes['microcopyStartAt'] )
? strtotime( trim( $attributes['microcopyStartAt'] ), $now )
: false;
if ( is_int( $start ) && $now < $start ) {
return false;
}
$end = isset( $attributes['microcopyEndAt'] ) && is_string( $attributes['microcopyEndAt'] )
? strtotime( trim( $attributes['microcopyEndAt'] ), $now )
: false;
if ( is_int( $end ) && $now > $end ) {
return false;
}
return true;
}
/**
* Stop caching at the next window transition (the end is inclusive).
*
* @param array $attributes Block attributes.
*/
private function renderCacheTtl( array $attributes ): int {
$now = $this->clock->now()->getTimestamp();
$ttl = self::RENDER_CACHE_TTL_SECONDS;
foreach ( array(
'microcopyStartAt' => 0,
'microcopyEndAt' => 1,
) as $key => $offset ) {
$boundary = isset( $attributes[ $key ] ) && is_string( $attributes[ $key ] )
? strtotime( trim( $attributes[ $key ] ), $now )
: false;
if ( is_int( $boundary ) && $boundary + $offset > $now ) {
$ttl = min( $ttl, $boundary + $offset - $now );
}
}
return $ttl;
}
/**
* Derive the provider-crossing search query.
*
* The server render path uses the saved title/brand as a deterministic
* fallback query. When affilun Cloud extraction is available at save/search
* time, the saved keywords already carry the extracted product name/model signal.
*
* @param ProductSnapshot $snapshot Snapshot to derive from.
*/
private static function deriveSearchQuery( ProductSnapshot $snapshot ): string {
$parts = array();
if ( $snapshot->brand() !== null ) {
$parts[] = $snapshot->brand();
}
$parts[] = $snapshot->title();
$query = preg_replace( '/\s+/u', ' ', implode( ' ', $parts ) );
if ( ! is_string( $query ) ) {
$query = $snapshot->title();
}
return trim( $query );
}
/**
* Build a provider-native search URL for the supplied query.
*
* @param string $providerKey Provider key.
* @param string $query Search query.
*/
private static function buildProviderSearchUrl( string $providerKey, string $query ): ?string {
$encoded = rawurlencode( $query );
switch ( $providerKey ) {
case 'amazon':
return 'https://www.amazon.co.jp/s?k=' . $encoded;
case 'rakuten':
return 'https://search.rakuten.co.jp/search/mall/' . $encoded . '/';
case 'yahoo_shopping':
return 'https://shopping.yahoo.co.jp/search?p=' . $encoded;
case 'mercari':
return 'https://jp.mercari.com/search?keyword=' . $encoded;
default:
return null;
}
}
/**
* Build the §6.3 adhoc render-cache key for the supplied
* record + block attributes, or null when the cache wiring
* is incomplete (no `$renderCache` / `$cacheKeys` injected)
* or the inputs would be rejected by the key factory.
*
* @param ProductRecord $record Persisted product
* being rendered.
* @param array $attributes Block attributes
* used to derive the
* §6.3 `configHash`.
* @param string $template Resolved template
* key.
*/
private function resolveCacheKey(
ProductRecord $record,
array $attributes,
string $template
): ?string {
if ( $this->cacheKeys === null || $this->renderCache === null ) {
return null;
}
$id = $record->id();
if ( $id === null || $id < 1 ) {
return null;
}
try {
$productsHash = $this->cacheKeys->productsHash(
array( (string) $id ),
self::RENDER_CACHE_SORT_KEY
);
$configHash = $this->cacheKeys->configHash(
CacheKeyFactory::filterRelevantAttrs( $attributes, self::RENDER_CACHE_KIND )
);
return $this->cacheKeys->renderAdhocKey(
self::RENDER_CACHE_KIND,
$productsHash,
$template,
$configHash
);
} catch ( Throwable $unused ) {
unset( $unused );
return null;
}
}
/**
* Resolve the §4.3 `productId` attribute to a positive int, or
* null when absent / invalid.
*
* @param array $attributes Block attributes.
*/
private function resolveProductId( array $attributes ): ?int {
if ( ! array_key_exists( 'productId', $attributes ) ) {
return null;
}
$raw = $attributes['productId'];
if ( is_int( $raw ) ) {
return $raw >= 1 ? $raw : null;
}
if ( is_string( $raw ) || is_float( $raw ) ) {
$parsed = filter_var(
is_string( $raw ) ? trim( $raw ) : (string) $raw,
FILTER_VALIDATE_INT,
array( 'options' => array( 'min_range' => 1 ) )
);
return $parsed === false ? null : $parsed;
}
return null;
}
/**
* Resolve the §4.3 `template` attribute, falling back to the
* configured rendering default when absent or grammar-invalid.
*
* @param array $attributes Block attributes.
*/
private function resolveTemplate( array $attributes ): string {
$default = $this->renderingDefaults->defaultTemplate();
if ( ! array_key_exists( 'template', $attributes ) ) {
return $default;
}
$raw = $attributes['template'];
if ( ! is_string( $raw ) ) {
return $default;
}
$trimmed = trim( $raw );
if ( preg_match( self::TEMPLATE_PATTERN, $trimmed ) !== 1 ) {
return $default;
}
return $trimmed;
}
/**
* Compose custom root classes from visual settings + user class.
*
* @param array $attributes Block attributes.
*/
private function composeCustomRootClass( array $attributes ): string {
$classes = array();
$variant = $this->resolveTemplateVariant( $attributes );
if ( $variant !== 'default' ) {
$classes[] = 'affilun-design-' . $variant;
}
$classes[] = 'affilun-color-scheme-' . $this->resolveColorScheme( $attributes );
$buttonLayout = $this->resolveButtonLayout( $attributes );
if ( $buttonLayout !== 'wrap' ) {
$classes[] = 'affilun-buttons-' . $buttonLayout;
}
$buttonShape = $this->resolveButtonShape( $attributes );
if ( $buttonShape !== 'rounded' ) {
$classes[] = 'affilun-button-shape-' . $buttonShape;
}
$buttonTone = $this->resolveButtonTone( $attributes );
if ( $buttonTone !== 'brand' ) {
$classes[] = 'affilun-button-tone-' . $buttonTone;
}
$custom = $this->resolveCustomCssClass( $attributes );
if ( $custom !== null ) {
$classes[] = $custom;
}
return implode( ' ', $classes );
}
/**
* Resolve the design preset attribute.
*
* @param array $attributes Block attributes.
*/
private function resolveTemplateVariant( array $attributes ): string {
$default = $this->renderingDefaults->defaultDesignPreset();
if ( ! array_key_exists( 'templateVariant', $attributes ) || ! is_string( $attributes['templateVariant'] ) ) {
return $default;
}
$variant = trim( $attributes['templateVariant'] );
return in_array( $variant, array( 'default', 'flat', 'outlined', 'soft' ), true ) ? $variant : $default;
}
/**
* Resolve button layout visual setting.
*
* @param array $attributes Block attributes.
*/
private function resolveButtonLayout( array $attributes ): string {
$default = $this->renderingDefaults->defaultButtonLayout();
if ( ! array_key_exists( 'buttonLayout', $attributes ) || ! is_string( $attributes['buttonLayout'] ) ) {
return $default;
}
$layout = trim( $attributes['buttonLayout'] );
return in_array( $layout, array( 'wrap', 'stacked', 'full' ), true ) ? $layout : $default;
}
/**
* Resolve button shape visual setting.
*
* @param array $attributes Block attributes.
*/
private function resolveButtonShape( array $attributes ): string {
$default = $this->renderingDefaults->defaultButtonShape();
if ( ! array_key_exists( 'buttonShape', $attributes ) || ! is_string( $attributes['buttonShape'] ) ) {
return $default;
}
$shape = trim( $attributes['buttonShape'] );
return in_array( $shape, array( 'square', 'rounded', 'pill' ), true ) ? $shape : $default;
}
/**
* Resolve button color theme visual setting.
*
* @param array $attributes Block attributes.
*/
private function resolveButtonTone( array $attributes ): string {
$default = 'brand';
if ( ! array_key_exists( 'buttonTone', $attributes ) || ! is_string( $attributes['buttonTone'] ) ) {
return $default;
}
$tone = trim( $attributes['buttonTone'] );
return in_array( $tone, array( 'brand', 'muted', 'monochrome' ), true ) ? $tone : $default;
}
/**
* Resolve card color-scheme visual setting.
*
* @param array $attributes Block attributes.
*/
private function resolveColorScheme( array $attributes ): string {
$default = $this->renderingDefaults->defaultColorScheme();
if ( ! array_key_exists( 'colorScheme', $attributes ) || ! is_string( $attributes['colorScheme'] ) ) {
return $default;
}
$scheme = trim( $attributes['colorScheme'] );
return in_array( $scheme, array( 'light', 'dark', 'system' ), true ) ? $scheme : $default;
}
/**
* Include setting-derived visual defaults in the render-cache config hash.
*
* @param array $attributes Block attributes.
*
* @return array
*/
private function renderCacheAttributes( array $attributes ): array {
$cacheAttributes = $attributes;
$cacheAttributes['templateVariant'] = $this->resolveTemplateVariant( $attributes );
$cacheAttributes['buttonLayout'] = $this->resolveButtonLayout( $attributes );
$cacheAttributes['buttonShape'] = $this->resolveButtonShape( $attributes );
$cacheAttributes['buttonTone'] = $this->resolveButtonTone( $attributes );
$cacheAttributes['colorScheme'] = $this->resolveColorScheme( $attributes );
$cacheAttributes['activeMicrocopyLabel'] = $this->resolveActiveMicrocopyLabel( $attributes );
return $cacheAttributes;
}
/**
* Load rendering defaults from the persisted settings option.
*/
private static function loadRenderingDefaults(): RenderingDefaults {
if ( ! function_exists( 'get_option' ) ) {
return RenderingDefaults::defaults();
}
$stored = get_option( OptionStoreSettingsRepository::OPTION_KEY, array() );
if ( ! is_array( $stored ) || ! isset( $stored['rendering'] ) || ! is_array( $stored['rendering'] ) ) {
return RenderingDefaults::defaults();
}
try {
return RenderingDefaults::fromArray( $stored['rendering'] );
} catch ( \Throwable ) {
return RenderingDefaults::defaults();
}
}
/**
* Load rendering defaults through the settings repository.
*
* @param SettingsRepository $settingsRepository Settings repository.
*/
private static function loadRenderingDefaultsFromRepository( SettingsRepository $settingsRepository ): RenderingDefaults {
try {
return $settingsRepository->load()->rendering();
} catch ( Throwable ) {
return RenderingDefaults::defaults();
}
}
/**
* Resolve the §4.3 `customCssClass` attribute to a non-empty
* trimmed string, or null when absent / empty after trimming.
*
* @param array $attributes Block attributes.
*/
private function resolveCustomCssClass( array $attributes ): ?string {
if ( ! array_key_exists( 'customCssClass', $attributes ) ) {
return null;
}
$raw = $attributes['customCssClass'];
if ( ! is_string( $raw ) ) {
return null;
}
$trimmed = trim( $raw );
return $trimmed === '' ? null : $trimmed;
}
/**
* Resolve a §4.3 boolean attribute with a default fallback.
*
* @param array $attributes Block attributes.
* @param string $key Attribute key.
* @param bool $defaultValue Default when absent or non-bool-coercible.
*/
private function resolveBoolAttribute( array $attributes, string $key, bool $defaultValue ): bool {
if ( ! array_key_exists( $key, $attributes ) ) {
return $defaultValue;
}
$raw = $attributes[ $key ];
if ( is_bool( $raw ) ) {
return $raw;
}
if ( is_int( $raw ) || is_float( $raw ) ) {
return (bool) $raw;
}
if ( is_string( $raw ) ) {
$lower = strtolower( trim( $raw ) );
if ( $lower === 'true' || $lower === '1' ) {
return true;
}
if ( $lower === 'false' || $lower === '0' || $lower === '' ) {
return false;
}
}
return $defaultValue;
}
/**
* Render the §11.4 invalid-state fallback.
*
* Administrators see a developer-facing warning element so a
* misconfiguration is visible during authoring; non-admins see
* the empty string so the public surface preserves the §11.4
* "render nothing" posture.
*
* @param string $reason Developer-facing reason text.
*/
private function renderFallback( string $reason ): string {
$canManage = (bool) ( $this->currentUserCanProvider )( self::ADMIN_CAPABILITY );
if ( ! $canManage ) {
return '';
}
$escaped = function_exists( 'esc_html' )
? (string) esc_html( $reason )
: htmlspecialchars( $reason, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
return sprintf(
'%s
',
$escaped
);
}
/**
* Build a {@see ProductSnapshot} from the resolved persisted
* record.
*
* Phase 2 KI-009: thin delegation to the shared
* {@see SnapshotProjector::project()} utility so the
* field-mapping logic lives in exactly one place rather than
* being duplicated between the card and grid block
* renderers. Kept as a private method (rather than inlining
* the call site) so the call sites in
* {@see self::renderInternal()} stay readable and a future
* slice can re-introduce a per-renderer specialization
* without disturbing every caller.
*
* @param ProductRecord $record Resolved persisted record.
*/
private static function buildSnapshotFromRecord( ProductRecord $record ): ProductSnapshot {
return SnapshotProjector::project( $record );
}
}