Files
michaelschiemer/src/Framework/View/Dom/Transformer/PlaceholderTransformer.php
2025-11-24 21:28:25 +01:00

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 (&#039; -> ', &quot; -> ")
$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]);
}
}