feat(Docker): Upgrade to PHP 8.5.0RC3 with native ext-uri support

BREAKING CHANGE: Requires PHP 8.5.0RC3

Changes:
- Update Docker base image from php:8.4-fpm to php:8.5.0RC3-fpm
- Enable ext-uri for native WHATWG URL parsing support
- Update composer.json PHP requirement from ^8.4 to ^8.5
- Add ext-uri as required extension in composer.json
- Move URL classes from Url.php85/ to Url/ directory (now compatible)
- Remove temporary PHP 8.4 compatibility workarounds

Benefits:
- Native URL parsing with Uri\WhatWg\Url class
- Better performance for URL operations
- Future-proof with latest PHP features
- Eliminates PHP version compatibility issues
This commit is contained in:
2025-10-27 09:31:28 +01:00
parent 799f74f00a
commit c8b47e647d
81 changed files with 6988 additions and 601 deletions

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Transformer;
use App\Framework\DI\Container;
use App\Framework\Meta\MetaData;
use App\Framework\Template\Expression\PlaceholderProcessor;
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\TextNode;
use App\Framework\View\Processors\PlaceholderReplacer;
use App\Framework\View\RenderContext;
/**
* ForTransformer - AST-based foreach loop processor using PlaceholderProcessor
*
* Processes:
* - foreach attributes: <div foreach="$items as $item">
* - <for> elements: <for items="items" as="item">
*
* Uses PlaceholderProcessor for consistent placeholder replacement with ExpressionEvaluator
*/
final readonly class ForTransformer implements AstTransformer
{
private PlaceholderProcessor $placeholderProcessor;
public function __construct(
private Container $container
) {
$this->placeholderProcessor = new PlaceholderProcessor();
}
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
$this->processForLoops($document, $context);
return $document;
}
private function processForLoops(Node $node, RenderContext $context): void
{
if (!$node instanceof ElementNode && !$node instanceof DocumentNode) {
return;
}
// Process children first (depth-first for nested loops)
$children = $node->getChildren();
foreach ($children as $child) {
$this->processForLoops($child, $context);
}
// Process foreach attribute on this element
if ($node instanceof ElementNode && $node->hasAttribute('foreach')) {
$this->processForeachAttribute($node, $context);
}
// Process <for> elements
if ($node instanceof ElementNode && $node->getTagName() === 'for') {
$this->processForElement($node, $context);
}
}
/**
* Process foreach attribute: <div foreach="$items as $item">
*/
private function processForeachAttribute(ElementNode $node, RenderContext $context): void
{
$foreachExpr = $node->getAttribute('foreach');
// Parse "array as var" syntax (with or without $ prefix)
if (!preg_match('/^\$?(\w+)\s+as\s+\$?(\w+)$/', $foreachExpr, $matches)) {
return; // Invalid syntax
}
$dataKey = $matches[1];
$varName = $matches[2];
// Remove foreach attribute
$node->removeAttribute('foreach');
// Resolve items from context
$items = $this->resolveValue($context->data, $dataKey);
if (!is_iterable($items)) {
// Remove element if not iterable
$parent = $node->getParent();
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
$parent->removeChild($node);
}
return;
}
// Get parent and position
$parent = $node->getParent();
if (!($parent instanceof ElementNode || $parent instanceof DocumentNode)) {
return;
}
// Clone and process for each item
$fragments = [];
foreach ($items as $item) {
$clone = $node->clone();
// Process placeholders in cloned element
$this->replacePlaceholdersInNode($clone, $varName, $item);
$fragments[] = $clone;
}
// Replace original node with all fragments
$parent->removeChild($node);
foreach ($fragments as $fragment) {
$parent->appendChild($fragment);
}
}
/**
* Process <for> element: <for items="items" as="item">
*/
private function processForElement(ElementNode $node, RenderContext $context): void
{
// Support both syntaxes
$dataKey = $node->getAttribute('items') ?? $node->getAttribute('in');
$varName = $node->getAttribute('as') ?? $node->getAttribute('var');
if (!$dataKey || !$varName) {
return; // Invalid syntax
}
// Resolve items from context
$items = $this->resolveValue($context->data, $dataKey);
if (!is_iterable($items)) {
// Remove element if not iterable
$parent = $node->getParent();
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
$parent->removeChild($node);
}
return;
}
// Get parent and position
$parent = $node->getParent();
if (!($parent instanceof ElementNode || $parent instanceof DocumentNode)) {
return;
}
// Process children for each item
$fragments = [];
foreach ($items as $item) {
foreach ($node->getChildren() as $child) {
$clone = $child->clone();
// Process placeholders
$this->replacePlaceholdersInNode($clone, $varName, $item);
$fragments[] = $clone;
}
}
// Replace <for> element with processed fragments
$parent->removeChild($node);
foreach ($fragments as $fragment) {
$parent->appendChild($fragment);
}
}
/**
* Replace placeholders in a node and its children using PlaceholderProcessor
*/
private function replacePlaceholdersInNode(Node $node, string $varName, mixed $item): void
{
if ($node instanceof TextNode) {
// Process text content with PlaceholderProcessor
$node->setText(
$this->placeholderProcessor->processLoopVariable($node->getTextContent(), $varName, $item)
);
return;
}
if ($node instanceof ElementNode) {
// Process attributes - HTML decode first to handle entity-encoded quotes
foreach (array_keys($node->getAttributes()) as $attrName) {
$attrValue = $node->getAttribute($attrName);
if ($attrValue !== null) {
// Decode HTML entities (&#039; -> ', &quot; -> ")
$decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Process placeholders with decoded value
$processedValue = $this->placeholderProcessor->processLoopVariable($decodedValue, $varName, $item);
// 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->replacePlaceholdersInNode($child, $varName, $item);
}
}
}
/**
* Resolve nested property paths like "redis.key_sample"
*/
private function resolveValue(array $data, string $expr): mixed
{
$keys = explode('.', $expr);
$value = $data;
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value)) {
if (isset($value->$key)) {
$value = $value->$key;
} elseif (method_exists($value, $key)) {
$value = $value->$key();
} elseif (method_exists($value, 'get' . ucfirst($key))) {
$getterMethod = 'get' . ucfirst($key);
$value = $value->$getterMethod();
} else {
return null;
}
} else {
return null;
}
}
return $value;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\View\Dom\Transformer;
use App\Framework\Template\Expression\ExpressionEvaluator;
use App\Framework\Template\Processing\AstTransformer;
use App\Framework\View\Dom\DocumentNode;
use App\Framework\View\Dom\ElementNode;
@@ -19,17 +20,24 @@ use App\Framework\View\RenderContext;
* - Removes attribute if condition is truthy
*
* Supports:
* - Simple properties: if="user.isAdmin"
* - Dollar syntax: if="$count > 0", if="$user->isAdmin"
* - Dot notation (legacy): if="user.isAdmin", if="items.length > 0"
* - Comparisons: if="count > 5", if="status == 'active'"
* - Logical operators: if="user.isAdmin && user.isVerified"
* - Negation: if="!user.isBanned"
* - Array properties: if="items.length > 0"
* - Negation: if="!$user->isBanned", if="!user.isAdmin"
* - Array access: if="$user['role'] === 'admin'"
* - Method calls: if="collection.isEmpty()"
*
* Framework Pattern: readonly class, AST-based transformation
* Framework Pattern: readonly class, AST-based transformation, composition with ExpressionEvaluator
*/
final readonly class IfTransformer implements AstTransformer
{
private ExpressionEvaluator $evaluator;
public function __construct()
{
$this->evaluator = new ExpressionEvaluator();
}
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Process both 'if' and 'condition' attributes
@@ -81,180 +89,10 @@ final readonly class IfTransformer implements AstTransformer
}
/**
* Evaluates condition expression with support for operators
* Evaluates condition expression using ExpressionEvaluator
*/
private function evaluateCondition(array $data, string $condition): bool
{
$condition = trim($condition);
// Handle logical operators (&&, ||)
if (str_contains($condition, '&&')) {
$parts = array_map('trim', explode('&&', $condition));
foreach ($parts as $part) {
if (! $this->evaluateCondition($data, $part)) {
return false;
}
}
return true;
}
if (str_contains($condition, '||')) {
$parts = array_map('trim', explode('||', $condition));
foreach ($parts as $part) {
if ($this->evaluateCondition($data, $part)) {
return true;
}
}
return false;
}
// Handle negation (!)
if (str_starts_with($condition, '!')) {
$negatedCondition = trim(substr($condition, 1));
return ! $this->evaluateCondition($data, $negatedCondition);
}
// Handle comparison operators
foreach (['!=', '==', '>=', '<=', '>', '<'] as $operator) {
if (str_contains($condition, $operator)) {
[$left, $right] = array_map('trim', explode($operator, $condition, 2));
$leftValue = $this->parseValue($data, $left);
$rightValue = $this->parseValue($data, $right);
return match ($operator) {
'!=' => $leftValue != $rightValue,
'==' => $leftValue == $rightValue,
'>=' => $leftValue >= $rightValue,
'<=' => $leftValue <= $rightValue,
'>' => $leftValue > $rightValue,
'<' => $leftValue < $rightValue,
};
}
}
// Simple property evaluation
$value = $this->resolveValue($data, $condition);
return $this->isTruthy($value);
}
/**
* Parse value from expression (property path, string literal, or number)
*/
private function parseValue(array $data, string $expr): mixed
{
$expr = trim($expr);
// String literal (quoted)
if ((str_starts_with($expr, '"') && str_ends_with($expr, '"')) ||
(str_starts_with($expr, "'") && str_ends_with($expr, "'"))) {
return substr($expr, 1, -1);
}
// Number literal
if (is_numeric($expr)) {
return str_contains($expr, '.') ? (float) $expr : (int) $expr;
}
// Boolean literals
if ($expr === 'true') {
return true;
}
if ($expr === 'false') {
return false;
}
if ($expr === 'null') {
return null;
}
// Property path
return $this->resolveComplexValue($data, $expr);
}
/**
* Resolves complex expressions including method calls and array properties
*/
private function resolveComplexValue(array $data, string $expr): mixed
{
// Handle method calls like isEmpty()
if (str_contains($expr, '()')) {
$methodPos = strpos($expr, '()');
$basePath = substr($expr, 0, $methodPos);
$methodName = substr($basePath, strrpos($basePath, '.') + 1);
$objectPath = substr($basePath, 0, strrpos($basePath, '.'));
$object = $this->resolveValue($data, $objectPath);
if (is_object($object) && method_exists($object, $methodName)) {
return $object->$methodName();
}
return null;
}
// Handle .length property for arrays
if (str_ends_with($expr, '.length')) {
$basePath = substr($expr, 0, -7);
$value = $this->resolveValue($data, $basePath);
if (is_array($value)) {
return count($value);
}
if (is_object($value) && method_exists($value, 'count')) {
return $value->count();
}
if (is_countable($value)) {
return count($value);
}
return 0;
}
// Standard property path resolution
return $this->resolveValue($data, $expr);
}
/**
* Resolves nested property paths like "performance.opcacheMemoryUsage"
*/
private function resolveValue(array $data, string $expr): mixed
{
$keys = explode('.', $expr);
$value = $data;
foreach ($keys as $key) {
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;
}
}
return $value;
}
/**
* Check if value is truthy
*/
private function isTruthy(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_null($value)) {
return false;
}
if (is_string($value)) {
return trim($value) !== '';
}
if (is_numeric($value)) {
return $value != 0;
}
if (is_array($value)) {
return count($value) > 0;
}
return true;
return $this->evaluator->evaluateCondition($condition, $data);
}
}

View File

@@ -6,16 +6,19 @@ namespace App\Framework\View\Processors;
use App\Framework\DI\Container;
use App\Framework\Meta\MetaData;
use App\Framework\Template\Expression\PlaceholderProcessor;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RawHtml;
use App\Framework\View\RenderContext;
final class ForProcessor implements DomProcessor
{
private PlaceholderProcessor $placeholderProcessor;
public function __construct(
private Container $container,
) {
$this->placeholderProcessor = new PlaceholderProcessor();
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
@@ -40,8 +43,11 @@ final class ForProcessor implements DomProcessor
$forNodesOld = $dom->document->querySelectorAll('for[var][in]');
$forNodesNew = $dom->document->querySelectorAll('for[items][as]');
// Support foreach attribute on any element: <tr foreach="$models as $model">
$foreachNodes = $dom->document->querySelectorAll('[foreach]');
// Merge both nodesets
// Merge all nodesets
$forNodes = [];
foreach ($forNodesOld as $node) {
$forNodes[] = $node;
@@ -49,11 +55,29 @@ final class ForProcessor implements DomProcessor
foreach ($forNodesNew as $node) {
$forNodes[] = $node;
}
foreach ($foreachNodes as $node) {
$forNodes[] = $node;
}
foreach ($forNodes as $node) {
// Detect which syntax is being used
if ($node->hasAttribute('items') && $node->hasAttribute('as')) {
if ($node->hasAttribute('foreach')) {
// foreach attribute syntax: <tr foreach="$models as $model">
$foreachExpr = $node->getAttribute('foreach');
// Parse "array as var" syntax (with or without $ prefix)
if (preg_match('/^\$?(\w+)\s+as\s+\$?(\w+)$/', $foreachExpr, $matches)) {
$in = $matches[1];
$var = $matches[2];
} else {
// Invalid foreach syntax, skip this node
continue;
}
// Remove foreach attribute from element
$node->removeAttribute('foreach');
} elseif ($node->hasAttribute('items') && $node->hasAttribute('as')) {
// New syntax: <for items="arrayName" as="itemVar">
$in = $node->getAttribute('items');
$var = $node->getAttribute('as');
@@ -64,6 +88,10 @@ final class ForProcessor implements DomProcessor
}
$output = '';
// Check if this was a foreach attribute (already removed)
// We detect this by checking if node is NOT a <for> element
$isForeachAttribute = !in_array(strtolower($node->tagName), ['for']);
// Resolve items from context data or model
$items = $this->resolveValue($context->data, $in);
@@ -88,16 +116,23 @@ final class ForProcessor implements DomProcessor
controllerClass: $context->controllerClass
);
// Get innerHTML from cloned node
$innerHTML = $clone->innerHTML;
// For foreach attribute: process the entire element
// For <for> element: process only innerHTML
if ($isForeachAttribute) {
// Process entire element (e.g., <tr>)
$innerHTML = $clone->outerHTML;
} else {
// Get innerHTML from cloned node
$innerHTML = $clone->innerHTML;
// Handle case where DOM parser treats <for> as self-closing
if (trim($innerHTML) === '') {
$innerHTML = $this->collectSiblingContent($node, $dom);
// Handle case where DOM parser treats <for> as self-closing
if (trim($innerHTML) === '') {
$innerHTML = $this->collectSiblingContent($node, $dom);
}
}
// Replace loop variable placeholders
$innerHTML = $this->replaceLoopVariables($innerHTML, $var, $item);
// Replace loop variable placeholders using PlaceholderProcessor
$innerHTML = $this->placeholderProcessor->processLoopVariable($innerHTML, $var, $item);
// Process placeholders in loop content
$placeholderReplacer = $this->container->get(PlaceholderReplacer::class);
@@ -184,51 +219,6 @@ final class ForProcessor implements DomProcessor
return $value;
}
/**
* Replaces loop variable placeholders in the HTML content
*/
private function replaceLoopVariables(string $html, string $varName, mixed $item): string
{
$pattern = '/{{\\s*' . preg_quote($varName, '/') . '\\.([\\w]+)\\s*}}/';
return preg_replace_callback(
$pattern,
function ($matches) use ($item) {
$property = $matches[1];
if (is_array($item) && array_key_exists($property, $item)) {
$value = $item[$property];
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value instanceof RawHtml) {
return $value->content;
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} elseif (is_object($item) && isset($item->$property)) {
$value = $item->$property;
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value instanceof RawHtml) {
return $value->content;
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// Return placeholder unchanged if property not found
return $matches[0];
},
$html
);
}
/**
* Collects content from sibling nodes when <for> is treated as self-closing
*/

View File

@@ -18,12 +18,15 @@ final readonly class ForStringProcessor implements StringProcessor
public function process(string $content, RenderContext $context): string
{
error_log("🔧🔧🔧 ForStringProcessor::process() CALLED - Template: " . $context->template);
error_log("🔧 ForStringProcessor: Processing content, looking for <for> tags");
error_log("🔧 ForStringProcessor: Processing content, looking for <for> tags and foreach attributes");
error_log("🔧 ForStringProcessor: Content contains '<for': " . (strpos($content, '<for') !== false ? 'YES' : 'NO'));
error_log("🔧 ForStringProcessor: Content contains 'foreach=': " . (strpos($content, 'foreach=') !== false ? 'YES' : 'NO'));
error_log("🔧 ForStringProcessor: Available data keys: " . implode(', ', array_keys($context->data)));
// Process nested <for> loops iteratively from innermost to outermost
$result = $content;
// FIRST: Process foreach attributes (must be done before <for> tags to handle nested cases)
$result = $this->processForeachAttributes($content, $context);
// THEN: Process nested <for> loops iteratively from innermost to outermost
$maxIterations = 10; // Prevent infinite loops
$iteration = 0;
@@ -209,4 +212,146 @@ final readonly class ForStringProcessor implements StringProcessor
return $result;
}
/**
* Process foreach attributes on elements: <tr foreach="$models as $model">
*/
private function processForeachAttributes(string $content, RenderContext $context): string
{
// Pattern to match elements with foreach attribute
// Matches: <tagname foreach="$array as $var" ... > ... </tagname>
// OR: <tagname foreach="array as var" ... > ... </tagname> (without $ prefix)
$pattern = '/<([a-zA-Z][a-zA-Z0-9]*)\s+([^>]*?)foreach\s*=\s*["\']?\$?([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s+\$?([a-zA-Z_][a-zA-Z0-9_]*)["\']?([^>]*?)>(.*?)<\/\1>/s';
$result = preg_replace_callback(
$pattern,
function ($matches) use ($context) {
$tagName = $matches[1]; // e.g., "tr"
$beforeAttrs = $matches[2]; // attributes before foreach
$dataKey = $matches[3]; // e.g., "models"
$varName = $matches[4]; // e.g., "model"
$afterAttrs = $matches[5]; // attributes after foreach
$innerHTML = $matches[6]; // content inside the element
error_log("🔧 ForStringProcessor: Processing foreach attribute on <$tagName>");
error_log("🔧 ForStringProcessor: dataKey='$dataKey', varName='$varName'");
// Resolve the data array/collection
$data = $this->resolveValue($context->data, $dataKey);
if (! is_array($data) && ! is_iterable($data)) {
error_log("🔧 ForStringProcessor: Data for '$dataKey' is not iterable: " . gettype($data));
return ''; // Remove the element if data is not iterable
}
// Combine attributes (remove foreach attribute)
$allAttrs = trim($beforeAttrs . ' ' . $afterAttrs);
$output = '';
foreach ($data as $item) {
// Replace loop variables in innerHTML
$processedInnerHTML = $this->replaceForeachVariables($innerHTML, $varName, $item);
// Reconstruct the element
$output .= "<{$tagName}" . ($allAttrs ? " {$allAttrs}" : '') . ">{$processedInnerHTML}</{$tagName}>";
}
error_log("🔧 ForStringProcessor: foreach processing complete, generated " . count($data) . " elements");
return $output;
},
$content
);
return $result;
}
/**
* Replace foreach loop variables, supporting both {{ $var.property }} and {{ $var['property'] }} syntax
*/
private function replaceForeachVariables(string $template, string $varName, mixed $item): string
{
error_log("🔧 ForStringProcessor: replaceForeachVariables called for varName='$varName'");
// Pattern 1: {{ $var.property }} or {{ var.property }} (dot notation)
$patternDot = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/';
// Pattern 2: {{ $var['property'] }} or {{ var['property'] }} (bracket notation with single quotes)
$patternBracketSingle = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\s*\[\s*\'([^\']+)\'\s*\]\s*\}\}/';
// Pattern 3: {{ $var["property"] }} or {{ var["property"] }} (bracket notation with double quotes)
$patternBracketDouble = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\s*\[\s*"([^"]+)"\s*\]\s*\}\}/';
// Replace all patterns
$result = preg_replace_callback(
$patternDot,
function ($matches) use ($item, $varName) {
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
},
$template
);
$result = preg_replace_callback(
$patternBracketSingle,
function ($matches) use ($item, $varName) {
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
},
$result
);
$result = preg_replace_callback(
$patternBracketDouble,
function ($matches) use ($item, $varName) {
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
},
$result
);
return $result;
}
/**
* Resolve a property from an item (array or object)
*/
private function resolveItemProperty(mixed $item, string $property, string $varName, string $originalPlaceholder): string
{
error_log("🔧 ForStringProcessor: Resolving property '$property' from item");
if (is_array($item) && array_key_exists($property, $item)) {
$value = $item[$property];
error_log("🔧 ForStringProcessor: Found property '$property' in array with value: " . var_export($value, true));
// Handle boolean values properly
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
// Handle null
if ($value === null) {
return '';
}
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} elseif (is_object($item) && isset($item->$property)) {
$value = $item->$property;
error_log("🔧 ForStringProcessor: Found property '$property' in object with value: " . var_export($value, true));
// Handle boolean values properly
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
// Handle null
if ($value === null) {
return '';
}
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
error_log("🔧 ForStringProcessor: Property '$property' not found, returning unchanged placeholder");
// Return placeholder unchanged if property not found
return $originalPlaceholder;
}
}

View File

@@ -55,8 +55,9 @@ final class PlaceholderReplacer implements StringProcessor
// 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*}}/',
'/{{\\s*\\$?([\\w.\\[\\]\'\"]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
function ($matches) use ($context) {
$expression = $matches[1];
$params = isset($matches[2]) ? trim($matches[2]) : null;
@@ -276,16 +277,34 @@ final class PlaceholderReplacer implements StringProcessor
private function resolveValue(array $data, string $expr): mixed
{
$keys = explode('.', $expr);
// Handle array bracket syntax: $var['key'] or $var["key"]
// Can be chained: $var['key1']['key2'] or mixed: $var.prop['key']
$originalExpr = $expr;
$value = $data;
foreach ($keys as $key) {
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;
// 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;
}
}
}

View File

@@ -12,6 +12,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Performance\PerformanceService;
use App\Framework\View\Dom\Transformer\AssetInjectorTransformer;
use App\Framework\View\Dom\Transformer\CommentStripTransformer;
use App\Framework\View\Dom\Transformer\ForTransformer;
use App\Framework\View\Dom\Transformer\HoneypotTransformer;
use App\Framework\View\Dom\Transformer\IfTransformer;
use App\Framework\View\Dom\Transformer\LayoutTagTransformer;
@@ -19,7 +20,6 @@ use App\Framework\View\Dom\Transformer\MetaManipulatorTransformer;
use App\Framework\View\Dom\Transformer\WhitespaceCleanupTransformer;
use App\Framework\View\Dom\Transformer\XComponentTransformer;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\Processors\ForStringProcessor;
use App\Framework\View\Processors\PlaceholderReplacer;
use App\Framework\View\Processors\VoidElementsSelfClosingProcessor;
@@ -33,11 +33,12 @@ final readonly class TemplateRendererInitializer
#[Initializer]
public function __invoke(): TemplateRenderer
{
// AST Transformers (new approach)
// AST Transformers (new approach) - Modern template processing
$astTransformers = [
// Core transformers (order matters!)
LayoutTagTransformer::class, // Process <layout> tags FIRST (before other processing)
XComponentTransformer::class, // Process <x-*> components (LiveComponents + HtmlComponents)
ForTransformer::class, // Process foreach loops and <for> elements (BEFORE if/placeholders)
IfTransformer::class, // Conditional rendering (if/condition attributes)
MetaManipulatorTransformer::class, // Set meta tags from context
AssetInjectorTransformer::class, // Inject Vite assets (CSS/JS)
@@ -49,11 +50,9 @@ final readonly class TemplateRendererInitializer
// TODO: Migrate remaining DOM processors to AST transformers:
// - ComponentProcessor (for <component> tags) - COMPLEX, keep in DOM for now
// - TableProcessor (for table rendering) - OPTIONAL
// - ForProcessor (DOM-based, we already have ForStringProcessor) - HANDLED
// - FormProcessor (for form handling) - OPTIONAL
$strings = [
ForStringProcessor::class, // ForStringProcessor MUST run first to process <for> loops before DOM parsing
PlaceholderReplacer::class, // PlaceholderReplacer handles simple {{ }} replacements
VoidElementsSelfClosingProcessor::class,
];