): string */ private $includeStrategy; /** * Construct a renderer wired to the supplied resolver, dumper * and (optionally overridden) include strategy. * * The default include strategy buffers output, `require`-s * the resolved template inside a hygienic loader that exposes * the dumped array as `$viewModel` and nothing else, and * returns the captured HTML string. Tests inject a * deterministic closure to bypass the filesystem entirely. * * @param TemplateResolver $templateResolver Whitelist resolver. * @param ViewModelArrayDumper $arrayDumper §8.2 / §8.4 serializer. * @param (callable(string, array): string)|null $includeStrategy Override include strategy. */ public function __construct( TemplateResolver $templateResolver, ViewModelArrayDumper $arrayDumper, ?callable $includeStrategy = null ) { $this->templateResolver = $templateResolver; $this->arrayDumper = $arrayDumper; $this->includeStrategy = $includeStrategy ?? self::defaultIncludeStrategy(); } /** * Render `$viewModel` to an HTML string. * * @param ProductCardViewModel|FallbackCardViewModel $viewModel View model to render. * @param RenderContext $context Active render context. * * @throws TemplateResolutionException When the template key is * invalid, the resolver * cannot locate the file * and the §13.3 fallback * attempt also fails, a * fallback view model's * own template cannot be * resolved, or when the * include strategy fails * to capture output (the * resulting RuntimeException * is propagated unmodified). */ public function render( ProductCardViewModel|FallbackCardViewModel $viewModel, RenderContext $context ): string { $templateKeyValue = $viewModel->template(); try { $templateKey = TemplateKey::fromString( $templateKeyValue ); } catch ( Throwable $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw TemplateResolutionException::notFound( $templateKeyValue, array() ); } $resolvedPath = $this->resolveWithFallback( $templateKey, $viewModel ); $array = $this->arrayDumper->dump( $viewModel ); $array['_render_meta'] = array( 'surface' => $context->surface(), 'locale' => $context->locale(), 'request_id' => $context->requestId(), 'is_amp' => $context->isAmp(), 'is_feed' => $context->isFeed(), 'tracking_enabled' => $context->trackingEnabled(), 'lazy_load_enabled' => $context->lazyLoadEnabled(), 'debug' => $context->debug(), ); return ( $this->includeStrategy )( $resolvedPath, $array ); } /** * Convenience wrapper for callers that already know they are * rendering the §8.4 fallback shape. * * Equivalent to {@see self::render()}; exposed as a separate * method so explicit error-path call sites read more clearly * at the call boundary. * * @param FallbackCardViewModel $viewModel Fallback view model. * @param RenderContext $context Active render context. * * @throws TemplateResolutionException When the fallback template * cannot be resolved or when * the include strategy fails * to capture output (any * RuntimeException raised by * the strategy is propagated * unmodified). */ public function renderFallback( FallbackCardViewModel $viewModel, RenderContext $context ): string { return $this->render( $viewModel, $context ); } /** * Resolve `$templateKey` against the resolver, attempting the * §13.3 fallback when the input is a {@see ProductCardViewModel} * and the original key cannot be located. * * Fallback view models bypass the secondary attempt entirely: * a missing fallback template surfaces as the original * exception so the operator can fix the wiring. * * @param TemplateKey $templateKey Validated key for the input view model. * @param ProductCardViewModel|FallbackCardViewModel $viewModel View model under render. * * @throws TemplateResolutionException When neither the original * key nor the fallback key * can be resolved. */ private function resolveWithFallback( TemplateKey $templateKey, ProductCardViewModel|FallbackCardViewModel $viewModel ): string { try { return $this->templateResolver->resolve( $templateKey ); } catch ( TemplateResolutionException $original ) { if ( $viewModel instanceof FallbackCardViewModel ) { throw $original; } try { $fallbackKey = TemplateKey::fromString( self::FALLBACK_TEMPLATE_KEY ); return $this->templateResolver->resolve( $fallbackKey ); } catch ( Throwable $unused ) { throw $original; } } } /** * Build the default include strategy: buffer output, require * the resolved file with `$viewModel` as the only template- * visible local, and return the captured string. * * The closure delegates the actual `require` to * {@see self::safeInclude()} so the renderer's own locals * cannot leak into the template scope. On failure the buffer * is drained and the original Throwable is rethrown wrapped * in a {@see RuntimeException} (with the prior preserved). * * @return callable(string, array): string */ private static function defaultIncludeStrategy(): callable { return static function ( string $resolvedPath, array $viewModel ): string { $level = ob_get_level(); ob_start(); try { self::safeInclude( $resolvedPath, $viewModel ); $captured = ob_get_clean(); } catch ( Throwable $e ) { while ( ob_get_level() > $level ) { ob_end_clean(); } $message = sprintf( 'ProductCardRenderer: include strategy failed for "%s": %s', $resolvedPath, $e->getMessage() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message, 0, $e ); } if ( $captured === false ) { $message = sprintf( 'ProductCardRenderer: failed to capture output buffer for "%s".', $resolvedPath ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } return $captured; }; } /** * Hygienic loader for the default include strategy. * * Parameter names use a `$__afl` prefix so the template file * cannot accidentally collide with renderer-internal locals. * Inside this method, only `$viewModel` and the prefixed * parameters are in scope; the template file therefore sees a * single visible local — `$viewModel` — that mirrors the §6.5 * receiving signature. * * @param string $__aflResolvedPath Absolute template path. * @param array $__aflViewModel Read-only view-model array. */ private static function safeInclude( string $__aflResolvedPath, array $__aflViewModel ): void { $viewModel = $__aflViewModel; require $__aflResolvedPath; } }