Files
michaelschiemer/src/Framework/View/Processors/PlaceholderReplacer.php
Michael Schiemer 70e45fb56e fix(Discovery): Add comprehensive debug logging for router initialization
- Add initializer count logging in DiscoveryServiceBootstrapper
- Add route structure analysis in RouterSetup
- Add request parameter logging in HttpRouter
- Update PHP production config for better OPcache handling
- Fix various config and error handling improvements
2025-10-27 22:23:18 +01:00

500 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\Config\AppConfig;
use App\Framework\Config\Environment;
use App\Framework\DI\Container;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\Template\Processing\StringProcessor;
use App\Framework\View\Functions\ImageSlotFunction;
use App\Framework\View\Functions\LazyComponentFunction;
use App\Framework\View\Functions\UrlFunction;
use App\Framework\View\RawHtml;
use App\Framework\View\RenderContext;
use App\Framework\View\TemplateFunctions;
use DateTime;
use DateTimeZone;
final class PlaceholderReplacer implements StringProcessor
{
public function __construct(
private readonly Container $container,
private readonly ComponentRegistry $componentRegistry
) {
}
// Erlaubte Template-Funktionen für zusätzliche Sicherheit
/**
* @var string[]
*/
private array $allowedTemplateFunctions = [
'date', 'format_date', 'format_currency', 'format_filesize',
'strtoupper', 'strtolower', 'ucfirst', 'trim', 'count', /*'imageslot'*/
];
public function process(string $html, RenderContext $context): string
{
// Template-Funktionen: {{ date('Y-m-d') }}, {{ format_currency(100) }}
$html = $this->replaceTemplateFunctions($html, $context);
// Triple curly braces for raw/unescaped HTML: {{{ $content }}} or {{{ content }}}
// Supports both old and new syntax for backwards compatibility
$html = preg_replace_callback(
'/{{{\\s*\\$?([\\w.]+)\\s*}}}/',
function ($matches) use ($context) {
$expression = $matches[1];
return $this->resolveRaw($context->data, $expression);
},
$html
);
// Standard Variablen und Methoden: {{ $item.getRelativeFile() }} or {{ item.getRelativeFile() }}
// Supports both old and new syntax for backwards compatibility
// Also supports array bracket syntax: {{ $model['key'] }} or {{ $model["key"] }}
return preg_replace_callback(
'/{{\\s*\\$?([\\w.\\[\\]\'\"]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
function ($matches) use ($context) {
$expression = $matches[1];
$params = isset($matches[2]) ? trim($matches[2]) : null;
if ($params !== null) {
return $this->resolveMethodCall($context->data, $expression, $params);
} else {
return $this->resolveEscaped($context->data, $expression, ENT_QUOTES | ENT_HTML5);
}
},
$html
);
}
private function replaceTemplateFunctions(string $html, RenderContext $context): string
{
return preg_replace_callback(
'/{{\\s*([a-zA-Z_][a-zA-Z0-9_]*)\\(([^)]*)\\)\\s*}}/',
function ($matches) use ($context) {
$functionName = $matches[1];
$params = trim($matches[2]);
$functions = new TemplateFunctions($this->container, ImageSlotFunction::class, LazyComponentFunction::class, UrlFunction::class);
if ($functions->has($functionName)) {
$function = $functions->get($functionName);
$args = $this->parseParams($params, $context->data);
if (is_callable($function)) {
return $function(...$args);
}
#return $function(...$args);
}
// Nur erlaubte Funktionen
if (! in_array($functionName, $this->allowedTemplateFunctions)) {
return $matches[0];
}
try {
$args = $this->parseParams($params, $context->data);
// Custom Template-Funktionen
if (method_exists($this, 'function_' . $functionName)) {
$result = $this->{'function_' . $functionName}(...$args);
return $result;
#return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// Standard PHP-Funktionen (begrenzt)
if (function_exists($functionName)) {
$result = $functionName(...$args);
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
} catch (\Throwable $e) {
return $matches[0];
}
return $matches[0];
},
$html
);
}
private function function_imageslot(string $slotName): string
{
$function = $this->container->get(ImageSlotFunction::class);
return ($function('slot1'));
}
// Custom Template-Funktionen
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]);
}
private function resolveMethodCall(array $data, string $expression, string $params): string
{
$parts = explode('.', $expression);
$methodName = array_pop($parts); // Letzter Teil ist der Methodenname
$objectPath = implode('.', $parts); // Pfad zum Objekt
// Objekt auflösen
$object = $this->resolveValue($data, $objectPath);
if (! is_object($object)) {
return '{{ ' . $expression . '() }}'; // Platzhalter beibehalten
}
if (! method_exists($object, $methodName)) {
return '{{ ' . $expression . '() }}'; // Platzhalter beibehalten
}
try {
// Parameter parsen (falls vorhanden)
$parsedParams = empty($params) ? [] : $this->parseParams($params, $data);
// Methode aufrufen
$result = $object->$methodName(...$parsedParams);
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} catch (\Throwable $e) {
// Bei Fehlern Platzhalter beibehalten
return '{{ ' . $expression . '() }}';
}
}
private function resolveRaw(array $data, string $expr): string
{
$value = $this->resolveValue($data, $expr);
if ($value === null) {
// Bleibt als Platzhalter stehen
return '{{{ ' . $expr . ' }}}';
}
// LiveComponentContract - automatisch rendern mit Wrapper
if ($value instanceof LiveComponentContract) {
return $this->componentRegistry->renderWithWrapper($value);
}
// RawHtml-Objekte - direkt ausgeben
if ($value instanceof RawHtml) {
return $value->content;
}
// HtmlElement-Objekte - direkt ausgeben
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
return (string) $value;
}
// Strings direkt ausgeben (KEIN escaping!)
if (is_string($value)) {
return $value;
}
// Arrays und komplexe Objekte können nicht direkt als String dargestellt werden
if (is_array($value)) {
return $this->handleArrayValue($expr, $value);
}
if (is_object($value) && ! method_exists($value, '__toString')) {
return $this->handleObjectValue($expr, $value);
}
return (string)$value;
}
private function resolveEscaped(array $data, string $expr, int $flags): string
{
$value = $this->resolveValue($data, $expr);
if ($value === null) {
// Bleibt als Platzhalter stehen
return '{{ ' . $expr . ' }}';
}
// LiveComponentContract - automatisch rendern mit Wrapper
// WICHTIG: Wrapper-HTML ist bereits escaped, also NICHT nochmal escapen!
if ($value instanceof LiveComponentContract) {
$wrapperHtml = $this->componentRegistry->renderWithWrapper($value);
// Wrap in RawHtml to prevent double-escaping by code below (line 271)
return $wrapperHtml;
}
// RawHtml-Objekte nicht escapen
if ($value instanceof RawHtml) {
return $value->content;
}
// HtmlElement-Objekte nicht escapen (Framework Components)
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
return (string) $value;
}
// Arrays und komplexe Objekte können nicht direkt als String dargestellt werden
if (is_array($value)) {
return $this->handleArrayValue($expr, $value);
}
if (is_object($value) && ! method_exists($value, '__toString')) {
return $this->handleObjectValue($expr, $value);
}
// Zusätzlicher Schutz gegen Array-to-String Conversion
if (is_array($value) || (is_object($value) && ! method_exists($value, '__toString'))) {
// In Debug: Exception mit Details werfen
if ($this->isDebugMode()) {
$type = is_array($value) ? 'array[' . count($value) . ']' : 'object(' . get_class($value) . ')';
throw new \InvalidArgumentException("Cannot convert {$type} to string in placeholder: {{ {$expr} }}");
}
// In Production: leerer String
return '';
}
return htmlspecialchars((string)$value, $flags, 'UTF-8');
}
private function resolveValue(array $data, string $expr): mixed
{
// Handle array bracket syntax: $var['key'] or $var["key"]
// Can be chained: $var['key1']['key2'] or mixed: $var.prop['key']
$originalExpr = $expr;
$value = $data;
// Split expression into parts, handling both dot notation and bracket notation
$pattern = '/([\\w]+)|\\[([\'"])([^\\2]+?)\\2\\]/';
preg_match_all($pattern, $expr, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
if (!empty($match[1])) {
// Dot notation: variable.property
$key = $match[1];
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value) && isset($value->$key)) {
$value = $value->$key;
} else {
return null;
}
} elseif (!empty($match[3])) {
// Bracket notation: variable['key'] or variable["key"]
$key = $match[3];
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} else {
return null;
}
}
}
return $value;
}
/**
* Parst Parameter aus einem 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-Literale: 'text' oder "text"
if (preg_match('/^[\'\"](.*)[\'\"]/s', $part, $matches)) {
$params[] = $matches[1];
continue;
}
// Zahlen
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;
}
// Variablen-Referenzen: $variable oder Pfade: objekt.eigenschaft
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->resolveValue($data, $part);
if ($value !== null) {
$params[] = $value;
continue;
}
} else {
// Einfache Variablennamen (ohne $ oder .) aus Template-Daten auflösen
$value = $this->resolveValue($data, $part);
if ($value !== null) {
$params[] = $value;
continue;
}
}
// Fallback: Als String behandeln
$params[] = $part;
}
return $params;
}
/**
* Behandelt Array-Werte in Templates - Debug vs Production Verhalten
*/
private function handleArrayValue(string $expr, array $value): string
{
// In Debug-Mode: Exception werfen um Entwickler auf das Problem hinzuweisen
if ($this->isDebugMode()) {
throw new \InvalidArgumentException(
"Template placeholder '{{ {$expr} }}' resolved to an array, which cannot be displayed as string. " .
"Use array access like '{{ {$expr}.0 }}' or iterate with <for> loop."
);
}
// In Production: Sinnvolle Fallback-Werte für häufige Arrays
if (empty($value)) {
return ''; // Leere Arrays werden zu leerem String
}
// Für Arrays mit nur einem Element: das Element zurückgeben
if (count($value) === 1 && ! is_array(reset($value)) && ! is_object(reset($value))) {
return htmlspecialchars((string)reset($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// Für einfache Arrays: Anzahl anzeigen
return '[' . count($value) . ' items]';
}
/**
* Behandelt Object-Werte in Templates - Debug vs Production Verhalten
*/
private function handleObjectValue(string $expr, object $value): string
{
// In Debug-Mode: Exception werfen
if ($this->isDebugMode()) {
throw new \InvalidArgumentException(
"Template placeholder '{{ {$expr} }}' resolved to object of type '" . get_class($value) . "', " .
"which cannot be displayed as string. Implement __toString() method or access specific properties."
);
}
// In Production: Klassenname anzeigen
$className = get_class($value);
$shortName = substr($className, strrpos($className, '\\') + 1);
return '[' . $shortName . ']';
}
/**
* Erkennt ob wir im Debug-Modus sind über AppConfig
*/
private function isDebugMode(): bool
{
try {
/** @var AppConfig $appConfig */
$appConfig = $this->container->get(AppConfig::class);
return $appConfig->isDebug();
} catch (\Throwable $e) {
// Fallback zu Environment falls AppConfig nicht verfügbar
try {
$env = $this->container->get(Environment::class);
return $env->getString('APP_ENV', 'production') === 'development';
} catch (\Throwable) {
return false; // Safe fallback: production mode
}
}
}
/**
* Teilt einen Parameter-String in einzelne Parameter auf
*/
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;
}
}