name }} * - Dot notation: {{ user.name }} * - Expressions: {{ $count > 0 }} * - Template functions: {{ date('Y-m-d') }} * - Raw HTML: {{{ $content }}} * * Framework Pattern: readonly class, AST-based transformation, composition with ExpressionEvaluator */ final readonly class PlaceholderTransformer implements AstTransformer { private ExpressionEvaluator $evaluator; private ValueFormatter $formatter; /** * @var string[] Erlaubte Template-Funktionen */ private array $allowedTemplateFunctions; public function __construct( private Container $container ) { $this->evaluator = new ExpressionEvaluator(); $this->formatter = new ValueFormatter($container); $this->allowedTemplateFunctions = [ 'date', 'format_date', 'format_currency', 'format_filesize', 'strtoupper', 'strtolower', 'ucfirst', 'trim', 'count', 'number_format', 'json_encode', ]; } public function transform(DocumentNode $document, RenderContext $context): DocumentNode { // Skip placeholder processing if placeholders were already processed by ForTransformer // ForTransformer processes placeholders in loop contexts with scopes // We only process placeholders that weren't processed yet (outside of loops) $this->processNode($document, $context); return $document; } /** * Process a node and its children recursively */ private function processNode(Node $node, RenderContext $context): void { if ($node instanceof TextNode) { $this->processTextNode($node, $context); return; } if ($node instanceof ElementNode) { $this->processElementNode($node, $context); return; } // Process children for DocumentNode and other container nodes foreach ($node->getChildren() as $child) { $this->processNode($child, $context); } } /** * Process placeholders in text content */ private function processTextNode(TextNode $node, RenderContext $context): void { $text = $node->getTextContent(); // Skip if text doesn't contain placeholders (already processed by ForTransformer) if (!str_contains($text, '{{')) { return; } $result = $this->processPlaceholders($text, $context); $processed = $result['html']; $isRawHtml = $result['isRawHtml']; // Wenn Raw HTML erkannt wurde, erstelle RawHtmlNode statt TextNode if ($isRawHtml) { // Ersetze TextNode durch RawHtmlNode $parent = $node->getParent(); if ($parent) { $rawHtmlNode = new RawHtmlNode($processed); // Verwende Reflection, um das Kind im Parent zu ersetzen (ähnlich wie XComponentTransformer) $parentReflection = new ReflectionObject($parent); $childrenProperty = $parentReflection->getProperty('children'); $children = $childrenProperty->getValue($parent); $index = array_search($node, $children, true); if ($index !== false) { $children[$index] = $rawHtmlNode; $childrenProperty->setValue($parent, $children); $rawHtmlNode->setParent($parent); $node->setParent(null); // Debug: Log replacement if (getenv('APP_DEBUG') === 'true') { error_log("PlaceholderTransformer: Replaced TextNode with RawHtmlNode (length=" . strlen($processed) . ")"); } } } } else { $node->setText($processed); } } /** * Process placeholders in element attributes and children */ private function processElementNode(ElementNode $node, RenderContext $context): void { // Process attributes foreach (array_keys($node->getAttributes()) as $attrName) { $attrValue = $node->getAttribute($attrName); if ($attrValue !== null) { // Skip processing for 'if', 'condition', and 'items'/'in' attributes // - IfTransformer will handle 'if' and 'condition' // - ForTransformer will handle 'items' and 'in' (needs to resolve to actual arrays) if ($attrName === 'if' || $attrName === 'condition' || $attrName === 'items' || $attrName === 'in') { continue; } // Decode HTML entities (' -> ', " -> ") $decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Check if this is a single placeholder that might return an array/object // (e.g., pagination="{{$pagination}}") if (preg_match('/^{{\s*([^}]+?)\s*}}$/', $decodedValue, $matches)) { $expression = trim($matches[1]); $allVariables = $context->getAllVariables(); try { $value = $this->evaluator->evaluate($expression, $allVariables); // If the value is an array or object, store it directly (not as string) // XComponentTransformer will handle it correctly if (is_array($value) || is_object($value)) { // Store as special marker that XComponentTransformer can recognize // We'll use a JSON-encoded string that XComponentTransformer can decode $node->setAttribute($attrName, json_encode($value, JSON_THROW_ON_ERROR)); continue; } } catch (\Throwable $e) { // If evaluation fails, fall through to normal processing } } // For href attributes, use raw processing to avoid escaping URLs // URLs should not be escaped (they will be escaped by setAttribute if needed) if ($attrName === 'href' && str_contains($decodedValue, '{{')) { $processedValue = $this->processPlaceholdersRaw($decodedValue, $context); } else { // Process placeholders with escaping $result = $this->processPlaceholders($decodedValue, $context); $processedValue = $result['html']; } // Set the processed value (will be re-encoded if needed during rendering) $node->setAttribute($attrName, $processedValue); } } // Process children recursively foreach ($node->getChildren() as $child) { $this->processNode($child, $context); } } /** * Process all placeholders in a string * * @return array{html: string, isRawHtml: bool} HTML-String und Flag, ob Raw HTML enthalten ist */ private function processPlaceholders(string $html, RenderContext $context): array { // Get all variables from scopes and data $allVariables = $context->getAllVariables(); // Prüfe, ob der Original-Text {{{ }}} Platzhalter enthält $containsRawPlaceholders = preg_match('/{{{/', $html) === 1; // Step 1: Process template functions first (e.g., {{ date('Y-m-d') }}) $html = $this->processTemplateFunctions($html, $context, $allVariables); // Step 2: Process raw HTML placeholders {{{ expression }} $html = $this->processRawPlaceholders($html, $allVariables); // Step 3: Process standard placeholders {{ expression }} $html = $this->processStandardPlaceholders($html, $allVariables); // Prüfe, ob das Ergebnis HTML-Tags enthält (wenn Raw Platzhalter vorhanden waren) $isRawHtml = $containsRawPlaceholders && preg_match('/<[a-z][\s\S]*>/i', $html) === 1; return ['html' => $html, 'isRawHtml' => $isRawHtml]; } /** * Process template functions: {{ functionName(params) }} */ private function processTemplateFunctions(string $html, RenderContext $context, array $allVariables): string { return preg_replace_callback( '/{{\\s*([a-zA-Z_][a-zA-Z0-9_]*)\\(([^)]*)\\)\\s*}}/', function ($matches) use ($context, $allVariables) { $functionName = $matches[1]; $params = trim($matches[2]); // Check TemplateFunctions registry first $functions = new TemplateFunctions( $this->container, AssetSlotFunction::class, LazyComponentFunction::class, UrlFunction::class, ViteTagsFunction::class ); if ($functions->has($functionName)) { $function = $functions->get($functionName); $args = $this->parseParams($params, $allVariables); if (is_callable($function)) { return $function(...$args); } } // Check allowed template functions if (!in_array($functionName, $this->allowedTemplateFunctions)) { return $matches[0]; // Return unchanged if not allowed } try { $args = $this->parseParams($params, $allVariables); // Custom template functions if (method_exists($this, 'function_' . $functionName)) { $result = $this->{'function_' . $functionName}(...$args); return $result; } // Standard PHP functions (limited) if (function_exists($functionName)) { $result = $functionName(...$args); return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } } catch (\Throwable $e) { return $matches[0]; // Return unchanged on error } return $matches[0]; }, $html ); } /** * Validate expression against dangerous patterns */ private function validateExpression(string $expression): void { $dangerousPatterns = [ '/eval\s*\(/i', '/exec\s*\(/i', '/system\s*\(/i', '/shell_exec\s*\(/i', '/passthru\s*\(/i', '/proc_open\s*\(/i', '/popen\s*\(/i', '/`.*`/', // Backticks '/\$_(GET|POST|REQUEST|COOKIE|SERVER|ENV|FILES)/i', // Superglobals ]; foreach ($dangerousPatterns as $pattern) { if (preg_match($pattern, $expression)) { throw PlaceholderException::invalidExpression( $expression, 'Potentially dangerous expression detected' ); } } } /** * Evaluate a literal value (string, number, boolean) */ private function evaluateLiteral(string $expression): mixed { $expression = trim($expression); // String literals if (preg_match('/^["\'](.*)["\']$/', $expression, $matches)) { return $matches[1]; } // Numbers if (is_numeric($expression)) { return str_contains($expression, '.') ? (float) $expression : (int) $expression; } // Boolean literals if ($expression === 'true') return true; if ($expression === 'false') return false; if ($expression === 'null') return null; // Return as string if nothing else matches return $expression; } /** * Format value for logging (safe for error_log) */ private function formatValueForLog(mixed $value): string { if (is_string($value)) { return strlen($value) > 50 ? substr($value, 0, 50) . '...' : $value; } if (is_array($value)) { return 'array[' . count($value) . ']'; } if (is_object($value)) { return get_class($value); } return (string) $value; } /** * Process raw HTML placeholders: {{{ expression }}} * Supports: {{{ $data_table }}}, {{{ data_table }}}, {{{ $pagination['first_url'] }}} */ private function processRawPlaceholders(string $html, array $allVariables): string { $debugMode = getenv('APP_DEBUG') === 'true'; return preg_replace_callback( '/{{{\\s*(\\$?[a-zA-Z0-9_]+(?:\\[[^\\]]+\\])*)\\s*}}}/', function ($matches) use ($allVariables, $debugMode) { $expression = trim($matches[1]); try { $this->validateExpression($expression); // Handle fallback syntax if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $fallbackMatches)) { $primaryExpression = trim($fallbackMatches[1]); $fallbackExpression = trim($fallbackMatches[2]); try { $this->validateExpression($primaryExpression); $value = $this->evaluator->evaluate($primaryExpression, $allVariables); if ($value !== null) { if ($debugMode) { error_log("[Placeholder] RAW SUCCESS: {$primaryExpression} -> " . $this->formatValueForLog($value)); } return $this->formatter->formatRaw($value); } } catch (\Throwable $e) { if ($debugMode) { error_log("[Placeholder] RAW FAILED: {$primaryExpression} - {$e->getMessage()}, using fallback"); } } $fallbackValue = $this->evaluateLiteral($fallbackExpression); if ($debugMode) { error_log("[Placeholder] RAW FALLBACK: Using '{$fallbackExpression}' -> " . $this->formatValueForLog($fallbackValue)); } return $this->formatter->formatRaw($fallbackValue); } $value = $this->evaluator->evaluate($expression, $allVariables); if ($debugMode && str_contains($expression, 'table')) { error_log("[Placeholder] RAW: expression={$expression}, type=" . gettype($value) . ", class=" . (is_object($value) ? get_class($value) : 'N/A') . ", isRawHtml=" . ($value instanceof RawHtml ? 'yes' : 'no')); } $formatted = $this->formatter->formatRaw($value); if ($debugMode && str_contains($expression, 'table')) { error_log("[Placeholder] RAW: formatted length=" . strlen($formatted) . ", starts with getMessage()}"); } return ''; } catch (\Throwable $e) { if ($debugMode) { error_log("[Placeholder] RAW EXCEPTION: {$expression} - {$e->getMessage()}"); } return ''; } }, $html ); } /** * Process placeholders in attributes without escaping (for URLs in href attributes) */ private function processPlaceholdersRaw(string $html, RenderContext $context): string { $allVariables = $context->getAllVariables(); // Process both {{{ }}} and {{ }} placeholders, but don't escape the result // First handle {{{ }}} placeholders $html = preg_replace_callback( '/{{{\\s*([^}]+?)\\s*}}}/', function ($matches) use ($allVariables) { $expression = trim($matches[1]); $value = $this->evaluator->evaluate($expression, $allVariables); return $this->formatter->formatRaw($value); }, $html ); // Then handle {{ }} placeholders (but don't escape) // Use the same logic as processStandardPlaceholders but without escaping return preg_replace_callback( '/{{\\s*([^}]+?)\\s*}}/', function ($matches) use ($allVariables) { $expression = trim($matches[1]); // Check for method call: expression(params) if (preg_match('/^(.+?)\\(\\s*([^)]*)\\s*\\)$/', $expression, $methodMatches)) { $methodExpr = trim($methodMatches[1]); $params = trim($methodMatches[2]); $value = $this->processMethodCall($methodExpr, $params, $allVariables); return (string) $value; } // Try to resolve array bracket notation first (e.g., $pagination['first_url'] or pagination['first_url']) // Pattern: $var['key'] or var['key'] or $var["key"] or var["key"] if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[(["\']?)([^"\'\]]+)\\2\\]$/', $expression, $arrayMatches)) { $varName = $arrayMatches[1]; $key = $arrayMatches[3]; // Check if variable exists in context if (array_key_exists($varName, $allVariables)) { $value = $allVariables[$varName]; // Handle numeric key if (is_numeric($key)) { $key = (int) $key; } // Access array or object property if (is_array($value) && array_key_exists($key, $value)) { $result = $value[$key]; // Convert to string without escaping if (is_bool($result)) { return $result ? 'true' : 'false'; } if ($result === null) { return ''; } return (string) $result; } if (is_object($value)) { if (isset($value->$key)) { $result = $value->$key; if (is_bool($result)) { return $result ? 'true' : 'false'; } if ($result === null) { return ''; } return (string) $result; } } } return ''; } // Try ExpressionEvaluator as fallback try { $value = $this->evaluator->evaluate($expression, $allVariables); // Convert to string without escaping if (is_bool($value)) { return $value ? 'true' : 'false'; } if ($value === null) { return ''; } if ($value instanceof RawHtml) { return $value->content; } return (string) $value; } catch (\Throwable $e) { // If evaluation fails, return original placeholder if (getenv('APP_DEBUG') === 'true') { error_log("PlaceholderTransformer::processPlaceholdersRaw: Failed to evaluate '{$expression}': " . $e->getMessage()); } return $matches[0]; } }, $html ); } /** * Process standard placeholders: {{ expression }} */ private function processStandardPlaceholders(string $html, array $allVariables): string { // Pattern matches: {{ expression }} where expression can contain: // - Variables: $var, var // - Array access: $var['key'], $var["key"], $var[0] // - Dot notation: var.property // - Method calls: object.method() // Note: We need to handle both $var['key'] and var['key'] syntax // Debug: Log available variables (only first time) static $logged = false; if (!$logged && !empty($allVariables)) { error_log("PlaceholderTransformer: Available variables: " . implode(', ', array_keys($allVariables))); $logged = true; } return preg_replace_callback( '/{{\\s*([^}]+?)\\s*}}/', function ($matches) use ($allVariables) { $expression = trim($matches[1]); // Debug: Log expression being processed if (str_contains($expression, 'stack') || str_contains($expression, 'container')) { error_log("PlaceholderTransformer: Processing expression: {$expression}"); error_log("PlaceholderTransformer: Available vars: " . implode(', ', array_keys($allVariables))); } // Check for method call: expression(params) if (preg_match('/^(.+?)\\(\\s*([^)]*)\\s*\\)$/', $expression, $methodMatches)) { $methodExpr = trim($methodMatches[1]); $params = trim($methodMatches[2]); return $this->processMethodCall($methodExpr, $params, $allVariables); } // Try to resolve array bracket notation first (e.g., $container['id'] or container['id']) // This handles cases where ExpressionEvaluator might not support nested array access // Pattern: $var['key'] or var['key'] or $var["key"] or var["key"] if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[(["\']?)([^"\'\]]+)\\2\\]$/', $expression, $arrayMatches)) { $varName = $arrayMatches[1]; $key = $arrayMatches[3]; // Check if variable exists in context if (array_key_exists($varName, $allVariables)) { $value = $allVariables[$varName]; // Handle numeric key if (is_numeric($key)) { $key = (int) $key; } // Access array or object property if (is_array($value)) { if (array_key_exists($key, $value)) { return $this->formatEscapedValue($value[$key]); } } elseif (is_object($value)) { // Try direct property access if (isset($value->$key)) { return $this->formatEscapedValue($value->$key); } // Try toArray() for Value Objects if (method_exists($value, 'toArray')) { $array = $value->toArray(); if (is_array($array) && array_key_exists($key, $array)) { return $this->formatEscapedValue($array[$key]); } } } } // If variable not found or key doesn't exist, fall through to ExpressionEvaluator } // Try dot notation: $stack.display_name or stack.display_name // Remove leading $ for variable lookup $varNameForLookup = ltrim($expression, '$'); if (str_contains($varNameForLookup, '.') && !str_contains($varNameForLookup, '[')) { $parts = explode('.', $varNameForLookup, 2); $varName = trim($parts[0]); $property = trim($parts[1]); if (array_key_exists($varName, $allVariables)) { $obj = $allVariables[$varName]; if (is_array($obj) && array_key_exists($property, $obj)) { return $this->formatEscapedValue($obj[$property]); } if (is_object($obj)) { if (isset($obj->$property)) { return $this->formatEscapedValue($obj->$property); } if (method_exists($obj, 'toArray')) { $array = $obj->toArray(); if (is_array($array) && array_key_exists($property, $array)) { return $this->formatEscapedValue($array[$property]); } } } } } // Handle fallback syntax: {{ $var ?? 'default' }} $debugMode = getenv('APP_DEBUG') === 'true'; if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $fallbackMatches)) { $primaryExpression = trim($fallbackMatches[1]); $fallbackExpression = trim($fallbackMatches[2]); try { $this->validateExpression($primaryExpression); $value = $this->evaluator->evaluate($primaryExpression, $allVariables); if ($value !== null) { if ($debugMode) { error_log("[Placeholder] SUCCESS: {$primaryExpression} -> " . $this->formatValueForLog($value)); } return $this->formatEscapedValue($value); } } catch (\Throwable $e) { if ($debugMode) { error_log("[Placeholder] FAILED: {$primaryExpression} - {$e->getMessage()}, using fallback"); } } // Use fallback $fallbackValue = $this->evaluateLiteral($fallbackExpression); if ($debugMode) { error_log("[Placeholder] FALLBACK: Using '{$fallbackExpression}' -> " . $this->formatValueForLog($fallbackValue)); } return $this->formatEscapedValue($fallbackValue); } // Fallback to ExpressionEvaluator for other expressions // ExpressionEvaluator supports: $var['key'], $var->prop, var.property // For array access with $, keep the $; for simple variables, ExpressionEvaluator handles both try { $this->validateExpression($expression); $value = $this->evaluator->evaluate($expression, $allVariables); if ($value === null) { if ($debugMode) { error_log("[Placeholder] NULL: {$expression} - Variable not found or returned null"); } return ''; } return $this->formatEscapedValue($value); } catch (PlaceholderException $e) { if ($debugMode) { error_log("[Placeholder] ERROR: {$expression} - {$e->getMessage()}"); } return ''; } catch (\Throwable $e) { if ($debugMode) { error_log("[Placeholder] EXCEPTION: {$expression} - {$e->getMessage()}"); } return ''; } }, $html ); } /** * Process method calls: {{ object.method(params) }} */ private function processMethodCall(string $expression, string $params, array $allVariables): string { $parts = explode('.', $expression); $methodName = array_pop($parts); $objectPath = implode('.', $parts); // Resolve object $object = $this->evaluator->evaluate($objectPath, $allVariables); if (!is_object($object)) { return '{{ ' . $expression . '() }}'; // Keep placeholder if object not found } if (!method_exists($object, $methodName)) { return '{{ ' . $expression . '() }}'; // Keep placeholder if method not found } try { $parsedParams = empty($params) ? [] : $this->parseParams($params, $allVariables); $result = $object->$methodName(...$parsedParams); return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } catch (\Throwable $e) { return '{{ ' . $expression . '() }}'; // Keep placeholder on error } } /** * Format value for escaped output */ private function formatEscapedValue(mixed $value): string { return $this->formatter->formatEscaped($value); } /** * Format value for raw output */ private function formatRawValue(mixed $value): string { return $this->formatter->formatRaw($value); } /** * Parse parameters from a parameter string */ private function parseParams(string $paramsString, array $data): array { if (empty(trim($paramsString))) { return []; } $params = []; $parts = $this->splitParams($paramsString); foreach ($parts as $part) { $part = trim($part); // String literals: 'text' or "text" if (preg_match('/^[\'\"](.*)[\'\"]$/s', $part, $matches)) { $params[] = $matches[1]; continue; } // Numbers if (is_numeric($part)) { $params[] = str_contains($part, '.') ? (float)$part : (int)$part; continue; } // Boolean if ($part === 'true') { $params[] = true; continue; } if ($part === 'false') { $params[] = false; continue; } // Null if ($part === 'null') { $params[] = null; continue; } // Variable references: $variable or paths: object.property if (str_starts_with($part, '$')) { $varName = substr($part, 1); if (array_key_exists($varName, $data)) { $params[] = $data[$varName]; continue; } } elseif (str_contains($part, '.')) { $value = $this->evaluator->evaluate($part, $data); if ($value !== null) { $params[] = $value; continue; } } else { // Simple variable names (without $ or .) resolve from template data $value = $this->evaluator->evaluate($part, $data); if ($value !== null) { $params[] = $value; continue; } } // Fallback: treat as string $params[] = $part; } return $params; } /** * Split parameter string into individual parameters */ private function splitParams(string $paramsString): array { $params = []; $current = ''; $inQuotes = false; $quoteChar = null; $length = strlen($paramsString); for ($i = 0; $i < $length; $i++) { $char = $paramsString[$i]; if (!$inQuotes && ($char === '"' || $char === "'")) { $inQuotes = true; $quoteChar = $char; $current .= $char; } elseif ($inQuotes && $char === $quoteChar) { $inQuotes = false; $quoteChar = null; $current .= $char; } elseif (!$inQuotes && $char === ',') { $params[] = trim($current); $current = ''; } else { $current .= $char; } } if ($current !== '') { $params[] = trim($current); } return $params; } /** * Custom template functions */ private function function_format_date(string|DateTime $date, string $format = 'Y-m-d H:i:s'): string { if (is_string($date)) { $date = new \DateTimeImmutable($date, new DateTimeZone('Europe/Berlin')); } return $date->format($format); } private function function_format_currency(float $amount, string $currency = 'EUR'): string { return number_format($amount, 2, ',', '.') . ' ' . $currency; } private function function_format_filesize(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $factor = floor((strlen((string)$bytes) - 1) / 3); return sprintf("%.1f %s", $bytes / pow(1024, $factor), $units[$factor]); } }