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:
238
src/Framework/View/Dom/Transformer/ForTransformer.php
Normal file
238
src/Framework/View/Dom/Transformer/ForTransformer.php
Normal 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 (' -> ', " -> ")
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user