Some checks failed
Deploy Application / deploy (push) Has been cancelled
884 lines
36 KiB
PHP
884 lines
36 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\View\Dom\Transformer;
|
|
|
|
use App\Framework\DI\Container;
|
|
use App\Framework\LiveComponents\ComponentRegistry;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\Template\Expression\ExpressionEvaluator;
|
|
use App\Framework\Template\Processing\AstTransformer;
|
|
use App\Framework\View\Dom\DocumentNode;
|
|
use App\Framework\View\Dom\ElementNode;
|
|
use App\Framework\View\Dom\Node;
|
|
use App\Framework\View\Dom\RawHtmlNode;
|
|
use App\Framework\View\Dom\TextNode;
|
|
use App\Framework\View\Exceptions\PlaceholderException;
|
|
use App\Framework\View\Formatting\ValueFormatter;
|
|
use App\Framework\View\Functions\AssetSlotFunction;
|
|
use App\Framework\View\Functions\LazyComponentFunction;
|
|
use App\Framework\View\Functions\UrlFunction;
|
|
use App\Framework\View\Functions\ViteTagsFunction;
|
|
use App\Framework\View\RawHtml;
|
|
use App\Framework\View\RenderContext;
|
|
use App\Framework\View\Table\Table;
|
|
use App\Framework\View\TemplateFunctions;
|
|
use DateTime;
|
|
use DateTimeZone;
|
|
use ReflectionObject;
|
|
|
|
/**
|
|
* PlaceholderTransformer - AST-based placeholder replacement
|
|
*
|
|
* Processes placeholders {{ expression }} in templates using ExpressionEvaluator.
|
|
* Supports nested scopes from ForTransformer for loop variables.
|
|
*
|
|
* Supports:
|
|
* - Simple variables: {{ $name }}, {{ name }}
|
|
* - Array access: {{ $user['email'] }}, {{ $items[0] }}
|
|
* - Object properties: {{ $user->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 <table=" . (str_starts_with($formatted, '<table') ? 'yes' : 'no'));
|
|
}
|
|
|
|
return $formatted;
|
|
|
|
} catch (PlaceholderException $e) {
|
|
if ($debugMode) {
|
|
error_log("[Placeholder] RAW ERROR: {$expression} - {$e->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]);
|
|
}
|
|
}
|
|
|