fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Caching\Analysis;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\View\Loading\TemplateLoader;
|
||||
|
||||
/**
|
||||
* Template Analyzer Initializer
|
||||
*
|
||||
* Registers TemplateAnalyzer interface with SmartTemplateAnalyzer implementation.
|
||||
* This must be registered before CacheManager is requested, as CacheManager depends on TemplateAnalyzer.
|
||||
*/
|
||||
final readonly class TemplateAnalyzerInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function __invoke(TemplateLoader $templateLoader): TemplateAnalyzer
|
||||
{
|
||||
// Register TemplateAnalyzer interface to SmartTemplateAnalyzer implementation
|
||||
// Always create and register (idempotent - singleton will return same instance if already registered)
|
||||
if (!$this->container->has(TemplateAnalyzer::class)) {
|
||||
$instance = new SmartTemplateAnalyzer($templateLoader);
|
||||
$this->container->singleton(TemplateAnalyzer::class, $instance);
|
||||
return $instance;
|
||||
}
|
||||
|
||||
// If already registered, we should not reach here in normal flow
|
||||
// But if we do, create a new instance anyway (singleton will handle deduplication)
|
||||
$instance = new SmartTemplateAnalyzer($templateLoader);
|
||||
$this->container->singleton(TemplateAnalyzer::class, $instance);
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,4 +161,37 @@ class CacheManager
|
||||
|
||||
return $invalidated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for CMS content by slug
|
||||
*
|
||||
* This method invalidates the cache for a specific CMS content item
|
||||
* by generating the same cache key that would be used when rendering.
|
||||
*/
|
||||
public function invalidateContentCache(string $slug): bool
|
||||
{
|
||||
// Generate the same TemplateContext that would be used for rendering
|
||||
$context = new TemplateContext(
|
||||
template: 'cms-content',
|
||||
data: [
|
||||
'slug' => $slug,
|
||||
]
|
||||
);
|
||||
|
||||
// Use FullPageCacheStrategy to generate the same key
|
||||
$strategy = $this->strategies['full_page'];
|
||||
$cacheKey = $strategy->generateKey($context);
|
||||
|
||||
return $this->cache->forget($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying Cache instance
|
||||
*
|
||||
* This is needed for ContentService to invalidate cache directly
|
||||
*/
|
||||
public function getCache(): Cache
|
||||
{
|
||||
return $this->cache;
|
||||
}
|
||||
}
|
||||
|
||||
56
src/Framework/View/Caching/CacheManagerInitializer.php
Normal file
56
src/Framework/View/Caching/CacheManagerInitializer.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Caching;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\View\Caching\Analysis\SmartTemplateAnalyzer;
|
||||
use App\Framework\View\Caching\Analysis\TemplateAnalyzer;
|
||||
use App\Framework\View\Loading\TemplateLoader;
|
||||
|
||||
final readonly class CacheManagerInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function __invoke(
|
||||
Cache $cache,
|
||||
TemplateLoader $templateLoader,
|
||||
TemplateAnalyzer $analyzer
|
||||
): CacheManager {
|
||||
// TemplateAnalyzer is now injected as dependency, ensuring proper initialization order
|
||||
// This ensures TemplateAnalyzerInitializer runs before CacheManagerInitializer
|
||||
|
||||
// Bind FragmentCache interface to TaggedFragmentCache implementation
|
||||
if (!$this->container->has(FragmentCache::class)) {
|
||||
$this->container->singleton(
|
||||
FragmentCache::class,
|
||||
fn () => new TaggedFragmentCache($cache)
|
||||
);
|
||||
}
|
||||
|
||||
// Get FragmentCache
|
||||
$fragmentCache = $this->container->get(FragmentCache::class);
|
||||
|
||||
$cacheManager = new CacheManager(
|
||||
cache: $cache,
|
||||
analyzer: $analyzer,
|
||||
fragmentCache: $fragmentCache,
|
||||
strategyMapping: []
|
||||
);
|
||||
|
||||
// Register CacheManager as singleton
|
||||
if (!$this->container->has(CacheManager::class)) {
|
||||
$this->container->singleton(CacheManager::class, $cacheManager);
|
||||
}
|
||||
|
||||
return $cacheManager;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,25 @@ final readonly class AdminHeader implements StaticComponent
|
||||
<button
|
||||
class="admin-action-btn"
|
||||
aria-label="Notifications"
|
||||
data-dropdown-trigger="notifications"
|
||||
popovertarget="notifications-popover"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="admin-action-btn__icon">🔔</span>
|
||||
<span class="admin-action-btn__badge admin-action-btn__badge--count">3</span>
|
||||
</button>
|
||||
|
||||
<x-popover id="notifications-popover" type="auto" position="bottom-end" aria-labelledby="notifications-trigger">
|
||||
<div class="admin-notifications-popover">
|
||||
<div class="admin-notifications-popover__header">
|
||||
<h3>Notifications</h3>
|
||||
</div>
|
||||
<ul class="admin-notifications-popover__list" role="list">
|
||||
<li class="admin-notifications-popover__item">No new notifications</li>
|
||||
</ul>
|
||||
</div>
|
||||
</x-popover>
|
||||
|
||||
<button
|
||||
class="admin-theme-toggle"
|
||||
data-theme-toggle
|
||||
@@ -70,33 +83,40 @@ final readonly class AdminHeader implements StaticComponent
|
||||
<span class="admin-theme-toggle__label">Theme</span>
|
||||
</button>
|
||||
|
||||
<div class="admin-user-menu" data-dropdown>
|
||||
<button class="admin-user-menu__trigger" data-dropdown-trigger="user-menu" aria-haspopup="true" aria-expanded="false">
|
||||
<div class="admin-user-menu">
|
||||
<button
|
||||
class="admin-user-menu__trigger"
|
||||
popovertarget="user-menu-popover"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<div class="admin-user-menu__avatar" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.875rem;">A</div>
|
||||
<span class="admin-user-menu__name">Admin</span>
|
||||
<span class="admin-user-menu__chevron">▼</span>
|
||||
</button>
|
||||
|
||||
<ul class="admin-user-menu__dropdown" role="menu">
|
||||
<li class="admin-user-menu__item">
|
||||
<a href="/admin/profile" class="admin-user-menu__link" role="menuitem">
|
||||
<span class="admin-user-menu__icon">👤</span>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="admin-user-menu__item">
|
||||
<a href="/admin/settings" class="admin-user-menu__link" role="menuitem">
|
||||
<span class="admin-user-menu__icon">⚙️</span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="admin-user-menu__item">
|
||||
<a href="/logout" class="admin-user-menu__link" role="menuitem">
|
||||
<span class="admin-user-menu__icon">🚪</span>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<x-popover id="user-menu-popover" type="auto" position="bottom-end" aria-labelledby="user-menu-trigger">
|
||||
<ul class="admin-user-menu__dropdown" role="menu">
|
||||
<li class="admin-user-menu__item">
|
||||
<a href="/admin/profile" class="admin-user-menu__link" role="menuitem">
|
||||
<span class="admin-user-menu__icon">👤</span>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="admin-user-menu__item">
|
||||
<a href="/admin/settings" class="admin-user-menu__link" role="menuitem">
|
||||
<span class="admin-user-menu__icon">⚙️</span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="admin-user-menu__item">
|
||||
<a href="/logout" class="admin-user-menu__link" role="menuitem">
|
||||
<span class="admin-user-menu__icon">🚪</span>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</x-popover>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
@@ -106,17 +126,14 @@ final readonly class AdminHeader implements StaticComponent
|
||||
{
|
||||
return <<<HTML
|
||||
<div class="admin-header__search">
|
||||
<div class="admin-search">
|
||||
<label for="admin-search-input" class="admin-visually-hidden">Search admin</label>
|
||||
<input
|
||||
type="search"
|
||||
id="admin-search-input"
|
||||
class="admin-search__input"
|
||||
placeholder="Search..."
|
||||
aria-label="Search"
|
||||
/>
|
||||
<span class="admin-search__icon" aria-hidden="true">🔍</span>
|
||||
</div>
|
||||
<x-search-form
|
||||
action="/admin/search"
|
||||
method="get"
|
||||
placeholder="Search..."
|
||||
variant="header"
|
||||
autocomplete="off"
|
||||
aria-label="Search admin"
|
||||
/>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
use App\Framework\View\ValueObjects\UIDataAttribute;
|
||||
|
||||
#[ComponentName('admin-sidebar')]
|
||||
final readonly class AdminSidebar implements StaticComponent
|
||||
@@ -53,8 +54,9 @@ final readonly class AdminSidebar implements StaticComponent
|
||||
$header = $this->buildHeader();
|
||||
$navigation = $this->buildNavigation();
|
||||
$footer = $this->buildFooter();
|
||||
$resizeHandle = '<div class="admin-sidebar__resize-handle" aria-label="Resize sidebar"></div>';
|
||||
|
||||
return $header . $navigation . $footer;
|
||||
return $header . $navigation . $footer . $resizeHandle;
|
||||
}
|
||||
|
||||
private function buildHeader(): string
|
||||
@@ -92,23 +94,37 @@ final readonly class AdminSidebar implements StaticComponent
|
||||
|
||||
private function renderSection(NavigationSection $section): string
|
||||
{
|
||||
$html = '<div class="admin-nav__section">';
|
||||
$sectionId = $this->generateSectionId($section->name);
|
||||
// Use name attribute for exclusive accordion behavior
|
||||
$html = '<details class="admin-nav__section" name="admin-nav-sections" ' . UIDataAttribute::SECTION_ID->value() . '="' . $this->escape($sectionId) . '">';
|
||||
|
||||
if ($section->name !== '') {
|
||||
$html .= '<h2 class="admin-nav__section-title">' . htmlspecialchars($section->name) . '</h2>';
|
||||
$chevronIcon = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>';
|
||||
$html .= '<summary class="admin-nav__section-toggle">';
|
||||
$html .= '<h2>' . htmlspecialchars($section->name) . '</h2>';
|
||||
$html .= $chevronIcon;
|
||||
$html .= '</summary>';
|
||||
}
|
||||
|
||||
$html .= '<ul class="admin-nav__list" role="list">';
|
||||
$html .= '<ul role="list">';
|
||||
|
||||
foreach ($section->items as $item) {
|
||||
$html .= $this->renderItem($item);
|
||||
}
|
||||
|
||||
$html .= '</ul></div>';
|
||||
$html .= '</ul></details>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a section ID from section name
|
||||
*/
|
||||
private function generateSectionId(string $name): string
|
||||
{
|
||||
return strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', trim($name)));
|
||||
}
|
||||
|
||||
private function renderItem(NavigationItem $item): string
|
||||
{
|
||||
$activeState = $item->isActive($this->currentPath)
|
||||
@@ -118,8 +134,8 @@ final readonly class AdminSidebar implements StaticComponent
|
||||
$iconHtml = $this->renderIcon($item->icon);
|
||||
|
||||
return <<<HTML
|
||||
<li class="admin-nav__item">
|
||||
<a href="{$this->escape($item->url)}" class="admin-nav__link" {$activeState}>
|
||||
<li>
|
||||
<a href="{$this->escape($item->url)}" {$activeState}>
|
||||
{$iconHtml}
|
||||
<span>{$this->escape($item->name)}</span>
|
||||
</a>
|
||||
@@ -137,7 +153,7 @@ final readonly class AdminSidebar implements StaticComponent
|
||||
$svgIcon = $this->getSvgIcon($icon);
|
||||
|
||||
if ($svgIcon !== null) {
|
||||
return '<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">' . $svgIcon . '</svg>';
|
||||
return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">' . $svgIcon . '</svg>';
|
||||
}
|
||||
|
||||
// Fallback to emoji or text
|
||||
@@ -183,19 +199,24 @@ final readonly class AdminSidebar implements StaticComponent
|
||||
private function buildFallbackNavigation(): string
|
||||
{
|
||||
// Fallback to old hardcoded navigation for backward compatibility
|
||||
$sectionId = $this->generateSectionId('Dashboard');
|
||||
$chevronIcon = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>';
|
||||
return <<<HTML
|
||||
<nav class="admin-nav">
|
||||
<div class="admin-nav__section">
|
||||
<h2 class="admin-nav__section-title">Dashboard</h2>
|
||||
<ul class="admin-nav__list" role="list">
|
||||
<li class="admin-nav__item">
|
||||
<a href="/admin" class="admin-nav__link" {$this->getActiveState('/admin')}>
|
||||
<details class="admin-nav__section" name="admin-nav-sections" data-section-id="{$sectionId}">
|
||||
<summary class="admin-nav__section-toggle">
|
||||
<h2>Dashboard</h2>
|
||||
{$chevronIcon}
|
||||
</summary>
|
||||
<ul role="list">
|
||||
<li>
|
||||
<a href="/admin" {$this->getActiveState('/admin')}>
|
||||
<span class="admin-nav__icon">📊</span>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
HTML;
|
||||
}
|
||||
@@ -239,20 +260,33 @@ final readonly class AdminSidebar implements StaticComponent
|
||||
if (is_array($menuData)) {
|
||||
try {
|
||||
return NavigationMenu::fromArray($menuData);
|
||||
} catch (\Exception) {
|
||||
} catch (\Exception $e) {
|
||||
error_log("AdminSidebar: Failed to parse navigation menu from array: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON string
|
||||
if (is_string($menuData)) {
|
||||
$decoded = json_decode($menuData, true);
|
||||
if (is_array($decoded)) {
|
||||
try {
|
||||
return NavigationMenu::fromArray($decoded);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
// Decode HTML entities first (template system escapes values in HTML attributes)
|
||||
$decodedJson = html_entity_decode($menuData, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
$decoded = json_decode($decodedJson, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log("AdminSidebar: JSON decode error: " . json_last_error_msg() . " - Data: " . substr($decodedJson, 0, 200));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
error_log("AdminSidebar: Decoded data is not an array: " . gettype($decoded));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return NavigationMenu::fromArray($decoded);
|
||||
} catch (\Exception $e) {
|
||||
error_log("AdminSidebar: Failed to parse navigation menu from decoded JSON: " . $e->getMessage() . " - Data: " . json_encode($decoded));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Components\Helpers\ComponentAttributeHelper;
|
||||
use App\Framework\View\Components\Helpers\ComponentClassBuilder;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
@@ -17,6 +19,7 @@ final readonly class Badge implements StaticComponent
|
||||
private string $variant;
|
||||
private string $size;
|
||||
private bool $pill;
|
||||
private array $attributes;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
@@ -24,26 +27,45 @@ final readonly class Badge implements StaticComponent
|
||||
) {
|
||||
// Content is the badge text
|
||||
$this->text = $content;
|
||||
$this->attributes = $attributes;
|
||||
|
||||
// Extract attributes with defaults
|
||||
$this->variant = $attributes['variant'] ?? 'default';
|
||||
$this->size = $attributes['size'] ?? 'md';
|
||||
$this->pill = isset($attributes['pill'])
|
||||
? filter_var($attributes['pill'], FILTER_VALIDATE_BOOLEAN)
|
||||
: false;
|
||||
// Extract attributes using Helper
|
||||
$this->variant = ComponentAttributeHelper::extractVariant($attributes, 'default');
|
||||
$this->size = ComponentAttributeHelper::extractSize($attributes, 'md');
|
||||
$this->pill = ComponentAttributeHelper::extractBoolean($attributes, 'pill', false);
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
{
|
||||
$span = new ElementNode('span');
|
||||
|
||||
// Build CSS classes
|
||||
$classes = ['badge', "badge--{$this->variant}", "badge--{$this->size}"];
|
||||
if ($this->pill) {
|
||||
$classes[] = 'badge--pill';
|
||||
// Build CSS classes using Helper with modifiers
|
||||
$modifiers = ['pill' => $this->pill];
|
||||
$additionalClasses = [];
|
||||
if (isset($this->attributes['class']) && is_string($this->attributes['class'])) {
|
||||
$additionalClasses[] = $this->attributes['class'];
|
||||
}
|
||||
|
||||
$span->setAttribute('class', implode(' ', $classes));
|
||||
// Build base classes (variant and size)
|
||||
$baseClasses = ComponentClassBuilder::build('badge', $this->variant, $this->size, []);
|
||||
$baseClassesArray = explode(' ', $baseClasses);
|
||||
|
||||
// Combine with modifiers
|
||||
$classes = ComponentClassBuilder::buildWithModifiers(
|
||||
'badge',
|
||||
$modifiers,
|
||||
array_merge($baseClassesArray, $additionalClasses)
|
||||
);
|
||||
$span->setAttribute('class', $classes);
|
||||
|
||||
// Apply additional attributes
|
||||
$filteredAttributes = ComponentAttributeHelper::filterSpecialAttributes(
|
||||
$this->attributes,
|
||||
['pill']
|
||||
);
|
||||
foreach ($filteredAttributes as $key => $value) {
|
||||
$span->setAttribute($key, (string)$value);
|
||||
}
|
||||
|
||||
// Add text content
|
||||
$textNode = new TextNode($this->text);
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Components\Helpers\ComponentAttributeHelper;
|
||||
use App\Framework\View\Components\Helpers\ComponentClassBuilder;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
@@ -21,6 +23,7 @@ final readonly class Button implements StaticComponent
|
||||
private bool $disabled;
|
||||
private bool $fullWidth;
|
||||
private ?string $icon;
|
||||
private array $attributes;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
@@ -28,19 +31,18 @@ final readonly class Button implements StaticComponent
|
||||
) {
|
||||
// Content is the button text
|
||||
$this->text = $content;
|
||||
$this->attributes = $attributes;
|
||||
|
||||
// Extract attributes with defaults
|
||||
$this->type = $attributes['type'] ?? 'button';
|
||||
$this->variant = $attributes['variant'] ?? 'primary';
|
||||
$this->size = $attributes['size'] ?? 'md';
|
||||
$this->href = $attributes['href'] ?? null;
|
||||
$this->disabled = isset($attributes['disabled'])
|
||||
? filter_var($attributes['disabled'], FILTER_VALIDATE_BOOLEAN)
|
||||
: false;
|
||||
$this->fullWidth = isset($attributes['full-width'])
|
||||
? filter_var($attributes['full-width'], FILTER_VALIDATE_BOOLEAN)
|
||||
: false;
|
||||
$this->icon = $attributes['icon'] ?? null;
|
||||
// Extract attributes using Helper
|
||||
$this->variant = ComponentAttributeHelper::extractVariant($attributes, 'primary');
|
||||
$this->size = ComponentAttributeHelper::extractSize($attributes, 'md');
|
||||
$this->type = ComponentAttributeHelper::extractString($attributes, 'type', 'button') ?? 'button';
|
||||
$hrefValue = ComponentAttributeHelper::extractString($attributes, 'href');
|
||||
// Treat empty string as null (e.g., when href="{{$page['url']}}" evaluates to empty)
|
||||
$this->href = ($hrefValue === '' || $hrefValue === null) ? null : $hrefValue;
|
||||
$this->disabled = ComponentAttributeHelper::extractBoolean($attributes, 'disabled', false);
|
||||
$this->fullWidth = ComponentAttributeHelper::extractBoolean($attributes, 'full-width', false);
|
||||
$this->icon = ComponentAttributeHelper::extractString($attributes, 'icon');
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
@@ -50,16 +52,22 @@ final readonly class Button implements StaticComponent
|
||||
? new ElementNode('a')
|
||||
: new ElementNode('button');
|
||||
|
||||
// Build CSS classes
|
||||
$classes = ['btn', "btn--{$this->variant}", "btn--{$this->size}"];
|
||||
// Build CSS classes using Helper
|
||||
$additionalClasses = [];
|
||||
if ($this->fullWidth) {
|
||||
$classes[] = 'btn--full-width';
|
||||
$additionalClasses[] = 'btn--full-width';
|
||||
}
|
||||
if ($this->disabled && $this->href !== null) {
|
||||
$classes[] = 'btn--disabled';
|
||||
$additionalClasses[] = 'btn--disabled';
|
||||
}
|
||||
|
||||
$element->setAttribute('class', implode(' ', $classes));
|
||||
// Add custom class from attributes if present
|
||||
if (isset($this->attributes['class']) && is_string($this->attributes['class'])) {
|
||||
$additionalClasses[] = $this->attributes['class'];
|
||||
}
|
||||
|
||||
$classes = ComponentClassBuilder::build('btn', $this->variant, $this->size, $additionalClasses);
|
||||
$element->setAttribute('class', $classes);
|
||||
|
||||
// Set element-specific attributes
|
||||
if ($this->href !== null) {
|
||||
@@ -74,6 +82,15 @@ final readonly class Button implements StaticComponent
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additional attributes (filtered)
|
||||
$filteredAttributes = ComponentAttributeHelper::filterSpecialAttributes(
|
||||
$this->attributes,
|
||||
['href', 'type', 'disabled', 'full-width', 'icon']
|
||||
);
|
||||
foreach ($filteredAttributes as $key => $value) {
|
||||
$element->setAttribute($key, (string)$value);
|
||||
}
|
||||
|
||||
// Build content
|
||||
$contentHtml = $this->icon !== null
|
||||
? "<span class=\"btn__icon\">{$this->icon}</span><span class=\"btn__text\">" . htmlspecialchars($this->text) . "</span>"
|
||||
|
||||
@@ -38,9 +38,7 @@ final readonly class ButtonGroup implements HtmlElement
|
||||
->withClass(implode(' ', $classes))
|
||||
->with('role', 'group');
|
||||
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$this->attributes = $this->attributes->with($name, $value);
|
||||
}
|
||||
$this->attributes = $this->attributes->merge($this->additionalAttributes);
|
||||
|
||||
$buttonsHtml = array_map(
|
||||
fn (Button $button) => (string) $button,
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Components\Helpers\ComponentAttributeHelper;
|
||||
use App\Framework\View\Components\Helpers\ComponentClassBuilder;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
@@ -20,6 +22,7 @@ final readonly class Card implements StaticComponent
|
||||
private ?string $imageSrc;
|
||||
private ?string $imageAlt;
|
||||
private string $variant;
|
||||
private array $attributes;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
@@ -27,23 +30,38 @@ final readonly class Card implements StaticComponent
|
||||
) {
|
||||
// Content is the card body
|
||||
$this->bodyContent = $content;
|
||||
$this->attributes = $attributes;
|
||||
|
||||
// Extract attributes with defaults
|
||||
$this->title = $attributes['title'] ?? null;
|
||||
$this->subtitle = $attributes['subtitle'] ?? null;
|
||||
$this->footer = $attributes['footer'] ?? null;
|
||||
$this->imageSrc = $attributes['image-src'] ?? null;
|
||||
$this->imageAlt = $attributes['image-alt'] ?? null;
|
||||
$this->variant = $attributes['variant'] ?? 'default';
|
||||
// Extract attributes using Helper
|
||||
$this->variant = ComponentAttributeHelper::extractVariant($attributes, 'default');
|
||||
$this->title = ComponentAttributeHelper::extractString($attributes, 'title');
|
||||
$this->subtitle = ComponentAttributeHelper::extractString($attributes, 'subtitle');
|
||||
$this->footer = ComponentAttributeHelper::extractString($attributes, 'footer');
|
||||
$this->imageSrc = ComponentAttributeHelper::extractString($attributes, 'image-src');
|
||||
$this->imageAlt = ComponentAttributeHelper::extractString($attributes, 'image-alt');
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
{
|
||||
$div = new ElementNode('div');
|
||||
|
||||
// Build CSS classes
|
||||
$classes = ['card', "card--{$this->variant}"];
|
||||
$div->setAttribute('class', implode(' ', $classes));
|
||||
// Build CSS classes using Helper
|
||||
$additionalClasses = [];
|
||||
if (isset($this->attributes['class']) && is_string($this->attributes['class'])) {
|
||||
$additionalClasses[] = $this->attributes['class'];
|
||||
}
|
||||
|
||||
$classes = ComponentClassBuilder::build('card', $this->variant, null, $additionalClasses);
|
||||
$div->setAttribute('class', $classes);
|
||||
|
||||
// Apply additional attributes
|
||||
$filteredAttributes = ComponentAttributeHelper::filterSpecialAttributes(
|
||||
$this->attributes,
|
||||
['title', 'subtitle', 'footer', 'image-src', 'image-alt']
|
||||
);
|
||||
foreach ($filteredAttributes as $key => $value) {
|
||||
$div->setAttribute($key, (string)$value);
|
||||
}
|
||||
|
||||
// Build complex nested content as HTML string
|
||||
$contentHtml = $this->buildContent();
|
||||
@@ -80,7 +98,14 @@ final readonly class Card implements StaticComponent
|
||||
}
|
||||
|
||||
// Body
|
||||
$elements[] = '<div class="card__body">' . htmlspecialchars($this->bodyContent) . '</div>';
|
||||
// Content may already be HTML (from processed placeholders), so check before escaping
|
||||
if ($this->isHtmlContent($this->bodyContent)) {
|
||||
// Content is already HTML - output directly
|
||||
$elements[] = '<div class="card__body">' . $this->bodyContent . '</div>';
|
||||
} else {
|
||||
// Content is plain text - escape it
|
||||
$elements[] = '<div class="card__body">' . htmlspecialchars($this->bodyContent) . '</div>';
|
||||
}
|
||||
|
||||
// Footer
|
||||
if ($this->footer !== null) {
|
||||
@@ -90,6 +115,36 @@ final readonly class Card implements StaticComponent
|
||||
return implode('', $elements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains HTML tags (is already HTML)
|
||||
* Uses a simple and fast check to avoid performance issues
|
||||
*/
|
||||
private function isHtmlContent(string $content): bool
|
||||
{
|
||||
// Trim whitespace to avoid false positives
|
||||
$trimmed = trim($content);
|
||||
|
||||
// Empty content is not HTML
|
||||
if (empty($trimmed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Simple check: if content starts with < and contains >, it's likely HTML
|
||||
// This is much faster than regex and avoids performance issues
|
||||
if (str_starts_with($trimmed, '<') && str_contains($trimmed, '>')) {
|
||||
// Additional check: ensure it's not just a single character or escaped text
|
||||
// Look for common HTML tags (table, div, span, etc.)
|
||||
$commonTags = ['<table', '<div', '<span', '<p', '<h', '<ul', '<ol', '<li', '<a', '<button'];
|
||||
foreach ($commonTags as $tag) {
|
||||
if (stripos($trimmed, $tag) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Factory methods for programmatic usage
|
||||
public static function create(string $bodyContent): self
|
||||
{
|
||||
|
||||
@@ -32,9 +32,7 @@ final readonly class Container implements HtmlElement
|
||||
}
|
||||
|
||||
$this->attributes = HtmlAttributes::empty()->withClass(implode(' ', $classes));
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$this->attributes = $this->attributes->with($name, $value);
|
||||
}
|
||||
$this->attributes = $this->attributes->merge($this->additionalAttributes);
|
||||
}
|
||||
|
||||
public static function create(string $content): self
|
||||
|
||||
126
src/Framework/View/Components/FooterNavigation.php
Normal file
126
src/Framework/View/Components/FooterNavigation.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Application\Admin\ValueObjects\NavigationMenu;
|
||||
use App\Application\Admin\ValueObjects\NavigationItem;
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
|
||||
#[ComponentName('footer-navigation')]
|
||||
final readonly class FooterNavigation implements StaticComponent
|
||||
{
|
||||
private NavigationMenu $menu;
|
||||
private string $currentPath;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
array $attributes = []
|
||||
) {
|
||||
$this->currentPath = $attributes['current-path'] ?? $attributes['currentPath'] ?? '/';
|
||||
$this->menu = $this->parseNavigationMenu($attributes);
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
{
|
||||
$nav = new ElementNode('nav');
|
||||
$nav->setAttribute('aria-label', 'Footer Navigation');
|
||||
|
||||
// Use TextNode for HTML content (will be parsed by HtmlRenderer)
|
||||
$content = new TextNode($this->buildNavigationContent());
|
||||
$nav->appendChild($content);
|
||||
|
||||
return $nav;
|
||||
}
|
||||
|
||||
private function buildNavigationContent(): string
|
||||
{
|
||||
$html = '<menu class="footer-nav">';
|
||||
|
||||
// Flatten all sections into a single menu (footer navigation typically doesn't have sections)
|
||||
foreach ($this->menu->sections as $section) {
|
||||
foreach ($section->items as $item) {
|
||||
$html .= $this->renderItem($item);
|
||||
}
|
||||
}
|
||||
|
||||
$html .= '</menu>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function renderItem(NavigationItem $item): string
|
||||
{
|
||||
$activeState = $item->isActive($this->currentPath)
|
||||
? 'aria-current="page"'
|
||||
: '';
|
||||
|
||||
return <<<HTML
|
||||
<li>
|
||||
<a href="{$this->escape($item->url)}" {$activeState}>
|
||||
{$this->escape($item->name)}
|
||||
</a>
|
||||
</li>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse navigation menu from attributes
|
||||
*/
|
||||
private function parseNavigationMenu(array $attributes): NavigationMenu
|
||||
{
|
||||
// Try different attribute names
|
||||
$menuData = $attributes['menu']
|
||||
?? $attributes['navigation-menu']
|
||||
?? $attributes['navigation_menu']
|
||||
?? $attributes['navigationMenu']
|
||||
?? null;
|
||||
|
||||
if ($menuData === null) {
|
||||
// Return empty menu if no data provided
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
|
||||
// Handle array directly
|
||||
if (is_array($menuData)) {
|
||||
try {
|
||||
return NavigationMenu::fromArray($menuData);
|
||||
} catch (\Exception) {
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON string
|
||||
if (is_string($menuData)) {
|
||||
// Decode HTML entities first (template system escapes values in HTML attributes)
|
||||
$decodedJson = html_entity_decode($menuData, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
$decoded = json_decode($decodedJson, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
|
||||
try {
|
||||
return NavigationMenu::fromArray($decoded);
|
||||
} catch (\Exception) {
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
}
|
||||
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
*/
|
||||
private function escape(string $string): string
|
||||
{
|
||||
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +118,7 @@ final readonly class FormCheckbox implements HtmlElement
|
||||
}
|
||||
|
||||
// Merge additional attributes
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$attributes = $attributes->with($name, $value);
|
||||
}
|
||||
$attributes = $attributes->merge($this->additionalAttributes);
|
||||
|
||||
$elements = [];
|
||||
|
||||
|
||||
@@ -212,9 +212,7 @@ final readonly class FormInput implements HtmlElement
|
||||
}
|
||||
|
||||
// Merge additional attributes
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$attributes = $attributes->with($name, $value);
|
||||
}
|
||||
$attributes = $attributes->merge($this->additionalAttributes);
|
||||
|
||||
$elements = [];
|
||||
|
||||
|
||||
@@ -56,9 +56,7 @@ final readonly class FormRadio implements HtmlElement
|
||||
$radioAttrs = $radioAttrs->withDisabled();
|
||||
}
|
||||
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$radioAttrs = $radioAttrs->with($name, $value);
|
||||
}
|
||||
$radioAttrs = $radioAttrs->merge($this->additionalAttributes);
|
||||
|
||||
$elements[] = StandardHtmlElement::create(TagName::INPUT, $radioAttrs);
|
||||
|
||||
|
||||
@@ -144,9 +144,7 @@ final readonly class FormSelect implements HtmlElement
|
||||
}
|
||||
|
||||
// Merge additional attributes
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$attributes = $attributes->with($name, $value);
|
||||
}
|
||||
$attributes = $attributes->merge($this->additionalAttributes);
|
||||
|
||||
// Build options
|
||||
$optionElements = [];
|
||||
|
||||
@@ -89,9 +89,7 @@ final readonly class FormTextarea implements HtmlElement
|
||||
->with('aria-describedby', "{$textareaId}-error");
|
||||
}
|
||||
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$textareaAttrs = $textareaAttrs->with($name, $value);
|
||||
}
|
||||
$textareaAttrs = $textareaAttrs->merge($this->additionalAttributes);
|
||||
|
||||
$elements[] = StandardHtmlElement::create(TagName::TEXTAREA, $textareaAttrs, $this->value ?? '');
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Components\Helpers;
|
||||
|
||||
/**
|
||||
* Component Attribute Helper
|
||||
*
|
||||
* Helper für gemeinsame Attribute-Verarbeitung in Components.
|
||||
* Verwendet Composition-Pattern statt Vererbung.
|
||||
*/
|
||||
final readonly class ComponentAttributeHelper
|
||||
{
|
||||
/**
|
||||
* Extrahiert Variante aus Attributen
|
||||
*
|
||||
* @param array<string, mixed> $attributes Component-Attribute
|
||||
* @param string $default Standard-Variante
|
||||
* @return string Variante
|
||||
*/
|
||||
public static function extractVariant(array $attributes, string $default = 'primary'): string
|
||||
{
|
||||
return isset($attributes['variant']) && is_string($attributes['variant'])
|
||||
? $attributes['variant']
|
||||
: $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Größe aus Attributen
|
||||
*
|
||||
* @param array<string, mixed> $attributes Component-Attribute
|
||||
* @param string $default Standard-Größe
|
||||
* @return string Größe
|
||||
*/
|
||||
public static function extractSize(array $attributes, string $default = 'md'): string
|
||||
{
|
||||
return isset($attributes['size']) && is_string($attributes['size'])
|
||||
? $attributes['size']
|
||||
: $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Boolean-Wert aus Attributen
|
||||
*
|
||||
* @param array<string, mixed> $attributes Component-Attribute
|
||||
* @param string $key Attribut-Schlüssel
|
||||
* @param bool $default Standard-Wert
|
||||
* @return bool Boolean-Wert
|
||||
*/
|
||||
public static function extractBoolean(array $attributes, string $key, bool $default = false): bool
|
||||
{
|
||||
if (!isset($attributes[$key])) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = $attributes[$key];
|
||||
|
||||
// String-Werte konvertieren
|
||||
if (is_string($value)) {
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default;
|
||||
}
|
||||
|
||||
// Boolean direkt zurückgeben
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtert spezielle Attribute heraus
|
||||
*
|
||||
* Entfernt variant, size, class und andere Component-spezifische Attribute,
|
||||
* damit die restlichen Attribute direkt an HTML-Elemente weitergegeben werden können.
|
||||
*
|
||||
* @param array<string, mixed> $attributes Component-Attribute
|
||||
* @param array<string> $exclude Zusätzliche auszuschließende Schlüssel
|
||||
* @return array<string, mixed> Gefilterte Attribute
|
||||
*/
|
||||
public static function filterSpecialAttributes(array $attributes, array $exclude = []): array
|
||||
{
|
||||
$defaultExclude = ['variant', 'size', 'class'];
|
||||
$allExclude = array_merge($defaultExclude, $exclude);
|
||||
|
||||
return array_filter(
|
||||
$attributes,
|
||||
fn(string $key) => !in_array($key, $allExclude, true),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert String-Wert aus Attributen
|
||||
*
|
||||
* @param array<string, mixed> $attributes Component-Attribute
|
||||
* @param string $key Attribut-Schlüssel
|
||||
* @param string|null $default Standard-Wert
|
||||
* @return string|null String-Wert oder null
|
||||
*/
|
||||
public static function extractString(array $attributes, string $key, ?string $default = null): ?string
|
||||
{
|
||||
if (!isset($attributes[$key])) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = $attributes[$key];
|
||||
|
||||
return is_string($value) ? $value : (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Components\Helpers;
|
||||
|
||||
/**
|
||||
* Component Class Builder
|
||||
*
|
||||
* Helper-Klasse für konsistentes CSS-Klassen-Building nach BEM-Syntax.
|
||||
* Verwendet Composition-Pattern statt Vererbung.
|
||||
*/
|
||||
final readonly class ComponentClassBuilder
|
||||
{
|
||||
/**
|
||||
* Baut CSS-Klassen-String nach BEM-Syntax
|
||||
*
|
||||
* @param string $baseClass Basis-Klasse (z.B. 'btn', 'card', 'badge')
|
||||
* @param string|null $variant Variante (z.B. 'primary', 'secondary')
|
||||
* @param string|null $size Größe (z.B. 'sm', 'md', 'lg')
|
||||
* @param array<string> $additional Zusätzliche Klassen
|
||||
* @return string CSS-Klassen-String
|
||||
*/
|
||||
public static function build(
|
||||
string $baseClass,
|
||||
?string $variant = null,
|
||||
?string $size = null,
|
||||
array $additional = []
|
||||
): string {
|
||||
$classes = [$baseClass];
|
||||
|
||||
// Variante hinzufügen (z.B. btn--primary)
|
||||
if ($variant !== null && $variant !== '') {
|
||||
$classes[] = "{$baseClass}--{$variant}";
|
||||
}
|
||||
|
||||
// Größe hinzufügen (z.B. btn--sm)
|
||||
if ($size !== null && $size !== '') {
|
||||
$classes[] = "{$baseClass}--{$size}";
|
||||
}
|
||||
|
||||
// Zusätzliche Klassen hinzufügen
|
||||
$allClasses = array_merge($classes, $additional);
|
||||
|
||||
// Leere Werte filtern und duplizierte entfernen
|
||||
$filteredClasses = array_filter($allClasses, fn(string $class) => $class !== '');
|
||||
$uniqueClasses = array_unique($filteredClasses);
|
||||
|
||||
return implode(' ', $uniqueClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut CSS-Klassen mit Modifiern
|
||||
*
|
||||
* @param string $baseClass Basis-Klasse
|
||||
* @param array<string, bool> $modifiers Modifier-Map (z.B. ['pill' => true, 'disabled' => false])
|
||||
* @param array<string> $additional Zusätzliche Klassen
|
||||
* @return string CSS-Klassen-String
|
||||
*/
|
||||
public static function buildWithModifiers(
|
||||
string $baseClass,
|
||||
array $modifiers = [],
|
||||
array $additional = []
|
||||
): string {
|
||||
$classes = [$baseClass];
|
||||
|
||||
// Modifier hinzufügen (nur wenn true)
|
||||
foreach ($modifiers as $modifier => $enabled) {
|
||||
if ($enabled) {
|
||||
$classes[] = "{$baseClass}--{$modifier}";
|
||||
}
|
||||
}
|
||||
|
||||
$allClasses = array_merge($classes, $additional);
|
||||
$filteredClasses = array_filter($allClasses, fn(string $class) => $class !== '');
|
||||
$uniqueClasses = array_unique($filteredClasses);
|
||||
|
||||
return implode(' ', $uniqueClasses);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,7 @@ final readonly class Image implements HtmlElement
|
||||
}
|
||||
|
||||
// Merge additional attributes
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$this->attributes = $this->attributes->with($name, $value);
|
||||
}
|
||||
$this->attributes = $this->attributes->merge($this->additionalAttributes);
|
||||
|
||||
$this->content = '';
|
||||
}
|
||||
|
||||
127
src/Framework/View/Components/Navigation.php
Normal file
127
src/Framework/View/Components/Navigation.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Application\Admin\ValueObjects\NavigationMenu;
|
||||
use App\Application\Admin\ValueObjects\NavigationSection;
|
||||
use App\Application\Admin\ValueObjects\NavigationItem;
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
|
||||
#[ComponentName('navigation')]
|
||||
final readonly class Navigation implements StaticComponent
|
||||
{
|
||||
private NavigationMenu $menu;
|
||||
private string $currentPath;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
array $attributes = []
|
||||
) {
|
||||
$this->currentPath = $attributes['current-path'] ?? $attributes['currentPath'] ?? '/';
|
||||
$this->menu = $this->parseNavigationMenu($attributes);
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
{
|
||||
$nav = new ElementNode('nav');
|
||||
$nav->setAttribute('aria-label', 'Hauptnavigation');
|
||||
|
||||
// Use TextNode for HTML content (will be parsed by HtmlRenderer)
|
||||
$content = new TextNode($this->buildNavigationContent());
|
||||
$nav->appendChild($content);
|
||||
|
||||
return $nav;
|
||||
}
|
||||
|
||||
private function buildNavigationContent(): string
|
||||
{
|
||||
$html = '<menu>';
|
||||
|
||||
// Flatten all sections into a single menu (frontend navigation typically doesn't have sections)
|
||||
foreach ($this->menu->sections as $section) {
|
||||
foreach ($section->items as $item) {
|
||||
$html .= $this->renderItem($item);
|
||||
}
|
||||
}
|
||||
|
||||
$html .= '</menu>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function renderItem(NavigationItem $item): string
|
||||
{
|
||||
$activeState = $item->isActive($this->currentPath)
|
||||
? 'aria-current="page"'
|
||||
: '';
|
||||
|
||||
return <<<HTML
|
||||
<li>
|
||||
<a href="{$this->escape($item->url)}" {$activeState}>
|
||||
{$this->escape($item->name)}
|
||||
</a>
|
||||
</li>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse navigation menu from attributes
|
||||
*/
|
||||
private function parseNavigationMenu(array $attributes): NavigationMenu
|
||||
{
|
||||
// Try different attribute names
|
||||
$menuData = $attributes['menu']
|
||||
?? $attributes['navigation-menu']
|
||||
?? $attributes['navigation_menu']
|
||||
?? $attributes['navigationMenu']
|
||||
?? null;
|
||||
|
||||
if ($menuData === null) {
|
||||
// Return empty menu if no data provided
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
|
||||
// Handle array directly
|
||||
if (is_array($menuData)) {
|
||||
try {
|
||||
return NavigationMenu::fromArray($menuData);
|
||||
} catch (\Exception) {
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON string
|
||||
if (is_string($menuData)) {
|
||||
// Decode HTML entities first (template system escapes values in HTML attributes)
|
||||
$decodedJson = html_entity_decode($menuData, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
$decoded = json_decode($decodedJson, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
|
||||
try {
|
||||
return NavigationMenu::fromArray($decoded);
|
||||
} catch (\Exception) {
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
}
|
||||
|
||||
return new NavigationMenu([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters
|
||||
*/
|
||||
private function escape(string $string): string
|
||||
{
|
||||
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
242
src/Framework/View/Components/Pagination.php
Normal file
242
src/Framework/View/Components/Pagination.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Framework\Core\ValueObjects\AccessibleLink;
|
||||
use App\Framework\Core\ValueObjects\HtmlLink;
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
|
||||
#[ComponentName('pagination')]
|
||||
final readonly class Pagination implements StaticComponent
|
||||
{
|
||||
private int $currentPage;
|
||||
private int $totalPages;
|
||||
private string $totalItemsFormatted;
|
||||
private string $itemsLabel;
|
||||
private bool $hasNext;
|
||||
private bool $hasPrevious;
|
||||
private ?string $nextUrl;
|
||||
private ?string $previousUrl;
|
||||
private string $firstUrl;
|
||||
private string $lastUrl;
|
||||
/** @var array<int, array{number: int|null, url: string|null, active: bool, ellipsis: bool}> */
|
||||
private array $pages;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
array $attributes = []
|
||||
) {
|
||||
// Parse pagination data from attribute (can be array or JSON string)
|
||||
$paginationData = $this->parsePaginationData($attributes['pagination'] ?? []);
|
||||
|
||||
$this->currentPage = (int) ($paginationData['current_page'] ?? 1);
|
||||
$this->totalPages = (int) ($paginationData['total_pages'] ?? 1);
|
||||
$this->totalItemsFormatted = $paginationData['total_items_formatted'] ?? '0';
|
||||
$this->itemsLabel = $attributes['items_label'] ?? 'items';
|
||||
// Boolean values are already normalized in parsePaginationData
|
||||
$this->hasNext = $paginationData['has_next'] ?? false;
|
||||
$this->hasPrevious = $paginationData['has_previous'] ?? false;
|
||||
$this->nextUrl = $paginationData['next_url'] ?? null;
|
||||
$this->previousUrl = $paginationData['previous_url'] ?? null;
|
||||
$this->firstUrl = $paginationData['first_url'] ?? '#';
|
||||
$this->lastUrl = $paginationData['last_url'] ?? '#';
|
||||
$this->pages = $paginationData['pages'] ?? [];
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
{
|
||||
$wrapper = new ElementNode('div');
|
||||
$wrapper->setAttribute('class', 'pagination-wrapper');
|
||||
|
||||
// Only render if we have pagination data
|
||||
if ($this->currentPage === 0 || $this->totalPages === 0) {
|
||||
return $wrapper; // Empty wrapper
|
||||
}
|
||||
|
||||
$contentHtml = $this->buildPaginationContent();
|
||||
$wrapper->appendChild(new TextNode($contentHtml));
|
||||
|
||||
return $wrapper;
|
||||
}
|
||||
|
||||
private function parsePaginationData(mixed $data): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
// Handle array directly (from template context)
|
||||
if (is_array($data)) {
|
||||
$result = $data;
|
||||
} elseif (is_string($data)) {
|
||||
// Handle JSON string
|
||||
try {
|
||||
$decoded = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
||||
$result = is_array($decoded) ? $decoded : [];
|
||||
} catch (\JsonException) {
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Normalize boolean values - ensure they are actual booleans
|
||||
// This handles cases where they might be strings "true"/"false" or integers 1/0
|
||||
if (isset($result['has_next'])) {
|
||||
$result['has_next'] = $this->normalizeBoolean($result['has_next']);
|
||||
}
|
||||
if (isset($result['has_previous'])) {
|
||||
$result['has_previous'] = $this->normalizeBoolean($result['has_previous']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a value to a boolean
|
||||
* Handles: true, false, "true", "false", 1, 0, "1", "0"
|
||||
*/
|
||||
private function normalizeBoolean(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return in_array(strtolower($value), ['true', '1', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value !== 0;
|
||||
}
|
||||
|
||||
return (bool) $value;
|
||||
}
|
||||
|
||||
private function buildPaginationContent(): string
|
||||
{
|
||||
$elements = [];
|
||||
|
||||
// Pagination Info
|
||||
$elements[] = '<div class="pagination-info">';
|
||||
$elements[] = '<p>';
|
||||
$elements[] = 'Showing page ' . htmlspecialchars((string)$this->currentPage, ENT_QUOTES, 'UTF-8');
|
||||
$elements[] = ' of ' . htmlspecialchars((string)$this->totalPages, ENT_QUOTES, 'UTF-8');
|
||||
$elements[] = ' (' . htmlspecialchars($this->totalItemsFormatted, ENT_QUOTES, 'UTF-8');
|
||||
$elements[] = ' total ' . htmlspecialchars($this->itemsLabel, ENT_QUOTES, 'UTF-8') . ')';
|
||||
$elements[] = '</p>';
|
||||
$elements[] = '</div>';
|
||||
|
||||
// Pagination Navigation
|
||||
$elements[] = '<nav aria-label="Pagination Navigation">';
|
||||
$elements[] = '<ul class="pagination" style="display: flex; list-style: none; padding: 0; gap: 0.25rem; flex-wrap: wrap; align-items: center; margin: 1rem 0;">';
|
||||
|
||||
// First Page - only show if not on first page
|
||||
if ($this->currentPage > 1) {
|
||||
$firstLink = AccessibleLink::create(
|
||||
$this->firstUrl,
|
||||
'⟪ First',
|
||||
'First page'
|
||||
)->withBaseLink(
|
||||
HtmlLink::create($this->firstUrl, '⟪ First')
|
||||
->withClass('page-link btn btn-sm btn-secondary')
|
||||
);
|
||||
$elements[] = '<li class="page-item">';
|
||||
$elements[] = $firstLink->toHtml();
|
||||
$elements[] = '</li>';
|
||||
}
|
||||
|
||||
// Previous - only show if there is a previous page
|
||||
if ($this->hasPrevious && $this->previousUrl !== null) {
|
||||
$previousLink = AccessibleLink::create(
|
||||
$this->previousUrl,
|
||||
'← Previous',
|
||||
'Previous page'
|
||||
)->withBaseLink(
|
||||
HtmlLink::create($this->previousUrl, '← Previous')
|
||||
->withClass('page-link btn btn-sm btn-secondary')
|
||||
);
|
||||
$elements[] = '<li class="page-item">';
|
||||
$elements[] = $previousLink->toHtml();
|
||||
$elements[] = '</li>';
|
||||
}
|
||||
|
||||
// Page Numbers
|
||||
foreach ($this->pages as $page) {
|
||||
if ($page['ellipsis']) {
|
||||
$elements[] = '<li class="page-item" style="padding: 0.5rem;">';
|
||||
$elements[] = '<span class="page-ellipsis" aria-hidden="true">...</span>';
|
||||
$elements[] = '</li>';
|
||||
} else {
|
||||
$isActive = $page['active'] ?? false;
|
||||
$pageNumber = $page['number'] ?? 0;
|
||||
$pageUrl = $page['url'] ?? '#';
|
||||
|
||||
$elements[] = '<li class="page-item">';
|
||||
|
||||
if ($isActive) {
|
||||
// Current page: render as <span> (not clickable)
|
||||
$elements[] = '<span class="page-link btn btn-sm btn-primary" aria-label="Page ' . htmlspecialchars((string)$pageNumber, ENT_QUOTES, 'UTF-8') . '" aria-current="page">';
|
||||
$elements[] = htmlspecialchars((string)$pageNumber, ENT_QUOTES, 'UTF-8');
|
||||
$elements[] = '</span>';
|
||||
} else {
|
||||
// Other pages: render as <a> link using AccessibleLink
|
||||
$pageLink = AccessibleLink::create(
|
||||
$pageUrl,
|
||||
(string)$pageNumber,
|
||||
"Page {$pageNumber}"
|
||||
)->withBaseLink(
|
||||
HtmlLink::create($pageUrl, (string)$pageNumber)
|
||||
->withClass('page-link btn btn-sm btn-secondary')
|
||||
);
|
||||
$elements[] = $pageLink->toHtml();
|
||||
}
|
||||
|
||||
$elements[] = '</li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Next - only show if there is actually a next page
|
||||
// Strict check: currentPage must be strictly less than totalPages
|
||||
// Also check that nextUrl exists (should be null on last page, but double-check)
|
||||
if ($this->currentPage < $this->totalPages && $this->nextUrl !== null && $this->nextUrl !== '#') {
|
||||
$nextLink = AccessibleLink::create(
|
||||
$this->nextUrl,
|
||||
'Next →',
|
||||
'Next page'
|
||||
)->withBaseLink(
|
||||
HtmlLink::create($this->nextUrl, 'Next →')
|
||||
->withClass('page-link btn btn-sm btn-secondary')
|
||||
);
|
||||
$elements[] = '<li class="page-item">';
|
||||
$elements[] = $nextLink->toHtml();
|
||||
$elements[] = '</li>';
|
||||
}
|
||||
|
||||
// Last Page - only show if we're not already on the last page
|
||||
// Strict check: currentPage must be strictly less than totalPages
|
||||
if ($this->currentPage < $this->totalPages && $this->lastUrl !== null && $this->lastUrl !== '#') {
|
||||
$lastLink = AccessibleLink::create(
|
||||
$this->lastUrl,
|
||||
'Last ⟫',
|
||||
'Last page'
|
||||
)->withBaseLink(
|
||||
HtmlLink::create($this->lastUrl, 'Last ⟫')
|
||||
->withClass('page-link btn btn-sm btn-secondary')
|
||||
);
|
||||
$elements[] = '<li class="page-item">';
|
||||
$elements[] = $lastLink->toHtml();
|
||||
$elements[] = '</li>';
|
||||
}
|
||||
|
||||
$elements[] = '</ul>';
|
||||
$elements[] = '</nav>';
|
||||
|
||||
return implode('', $elements);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ final readonly class Picture implements HtmlElement
|
||||
$this->attributes = HtmlAttributes::empty();
|
||||
|
||||
// Merge additional attributes
|
||||
foreach ($this->additionalAttributes->attributes as $name => $value) {
|
||||
$this->attributes = $this->attributes->with($name, $value);
|
||||
}
|
||||
$this->attributes = $this->attributes->merge($this->additionalAttributes);
|
||||
|
||||
$this->content = $this->buildContent();
|
||||
}
|
||||
|
||||
89
src/Framework/View/Components/Popover.php
Normal file
89
src/Framework/View/Components/Popover.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
|
||||
/**
|
||||
* Popover Component
|
||||
*
|
||||
* Wiederverwendbare Popover-Komponente mit nativer Popover API.
|
||||
* Baseline 2025: Popover API ist Baseline Newly available.
|
||||
*
|
||||
* Features:
|
||||
* - Native Browser-Support (kein JavaScript nötig für Basis-Funktionalität)
|
||||
* - Automatisches Focus-Management
|
||||
* - ESC-Key Support (native)
|
||||
* - Backdrop-Click Support (native)
|
||||
* - Unterstützt verschiedene Popover-Typen (auto, manual)
|
||||
*/
|
||||
#[ComponentName('popover')]
|
||||
final readonly class Popover implements StaticComponent
|
||||
{
|
||||
private string $id;
|
||||
private string $type;
|
||||
private ?string $anchor;
|
||||
private ?string $position;
|
||||
private ?string $ariaLabel;
|
||||
private ?string $ariaLabelledBy;
|
||||
private string $content;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
array $attributes = []
|
||||
) {
|
||||
$this->content = $content;
|
||||
|
||||
// Extract attributes with defaults
|
||||
$this->id = $attributes['id'] ?? 'popover-' . uniqid();
|
||||
$this->type = $attributes['type'] ?? $attributes['popover'] ?? 'auto';
|
||||
$this->anchor = $attributes['anchor'] ?? $attributes['anchor-element'] ?? null;
|
||||
$this->position = $attributes['position'] ?? null;
|
||||
$this->ariaLabel = $attributes['aria-label'] ?? $attributes['ariaLabel'] ?? null;
|
||||
$this->ariaLabelledBy = $attributes['aria-labelledby'] ?? $attributes['ariaLabelledBy'] ?? null;
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
{
|
||||
$popover = new ElementNode('div');
|
||||
$popover->setAttribute('id', $this->id);
|
||||
$popover->setAttribute('popover', $this->type);
|
||||
$popover->setAttribute('class', $this->getPopoverClass());
|
||||
|
||||
// Anchor positioning (if CSS Anchor Positioning is available)
|
||||
if ($this->anchor !== null) {
|
||||
$popover->setAttribute('anchor', $this->anchor);
|
||||
}
|
||||
|
||||
// ARIA attributes
|
||||
if ($this->ariaLabel !== null) {
|
||||
$popover->setAttribute('aria-label', $this->ariaLabel);
|
||||
}
|
||||
|
||||
if ($this->ariaLabelledBy !== null) {
|
||||
$popover->setAttribute('aria-labelledby', $this->ariaLabelledBy);
|
||||
}
|
||||
|
||||
// Use TextNode for HTML content (content is already processed HTML from template)
|
||||
$contentNode = new TextNode($this->content);
|
||||
$popover->appendChild($contentNode);
|
||||
|
||||
return $popover;
|
||||
}
|
||||
|
||||
private function getPopoverClass(): string
|
||||
{
|
||||
$baseClass = 'admin-popover';
|
||||
$typeClass = "admin-popover--{$this->type}";
|
||||
$positionClass = $this->position !== null ? "admin-popover--{$this->position}" : '';
|
||||
|
||||
return trim("{$baseClass} {$typeClass} {$positionClass}");
|
||||
}
|
||||
}
|
||||
|
||||
139
src/Framework/View/Components/Search.php
Normal file
139
src/Framework/View/Components/Search.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Components;
|
||||
|
||||
use App\Framework\View\Attributes\ComponentName;
|
||||
use App\Framework\View\Contracts\StaticComponent;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
|
||||
/**
|
||||
* Search Component
|
||||
*
|
||||
* Wiederverwendbare Search-Komponente mit semantischem <search> Element.
|
||||
* Baseline 2023: <search> Element ist Baseline Newly available.
|
||||
*
|
||||
* Features:
|
||||
* - Semantisches HTML mit <search> Element
|
||||
* - Accessibility-Support
|
||||
* - Konfigurierbare Varianten (header, sidebar, standalone)
|
||||
* - Optional: Live-Search mit debouncing
|
||||
*/
|
||||
#[ComponentName('search-form')]
|
||||
final readonly class Search implements StaticComponent
|
||||
{
|
||||
private string $action;
|
||||
private string $method;
|
||||
private string $name;
|
||||
private string $placeholder;
|
||||
private ?string $value;
|
||||
private string $variant;
|
||||
private bool $autocomplete;
|
||||
private ?string $ariaLabel;
|
||||
private ?string $ariaDescribedBy;
|
||||
private ?string $id;
|
||||
|
||||
public function __construct(
|
||||
string $content = '',
|
||||
array $attributes = []
|
||||
) {
|
||||
// Extract attributes with defaults
|
||||
$this->action = $attributes['action'] ?? '/search';
|
||||
$this->method = strtoupper($attributes['method'] ?? 'get');
|
||||
$this->name = $attributes['name'] ?? 'q';
|
||||
$this->placeholder = $attributes['placeholder'] ?? 'Search...';
|
||||
$this->value = $attributes['value'] ?? null;
|
||||
$this->variant = $attributes['variant'] ?? 'standalone';
|
||||
$this->autocomplete = isset($attributes['autocomplete'])
|
||||
? filter_var($attributes['autocomplete'], FILTER_VALIDATE_BOOLEAN)
|
||||
: true;
|
||||
$this->ariaLabel = $attributes['aria-label'] ?? $attributes['ariaLabel'] ?? null;
|
||||
$this->ariaDescribedBy = $attributes['aria-describedby'] ?? $attributes['ariaDescribedBy'] ?? null;
|
||||
$this->id = $attributes['id'] ?? null;
|
||||
}
|
||||
|
||||
public function getRootNode(): Node
|
||||
{
|
||||
$search = new ElementNode('search');
|
||||
$search->setAttribute('class', $this->getSearchClass());
|
||||
|
||||
if ($this->ariaLabel !== null) {
|
||||
$search->setAttribute('aria-label', $this->ariaLabel);
|
||||
}
|
||||
|
||||
// Build form structure using DOM nodes
|
||||
$form = new ElementNode('form');
|
||||
$form->setAttribute('action', $this->action);
|
||||
$form->setAttribute('method', $this->method);
|
||||
|
||||
// Input ID
|
||||
$inputId = $this->id ?? 'admin-search-input-' . uniqid();
|
||||
$ariaLabel = $this->ariaLabel ?? 'Search';
|
||||
|
||||
// Label
|
||||
$label = new ElementNode('label');
|
||||
$label->setAttribute('for', $inputId);
|
||||
$label->setAttribute('class', 'admin-visually-hidden');
|
||||
$label->appendChild(new TextNode($ariaLabel));
|
||||
$form->appendChild($label);
|
||||
|
||||
// Input
|
||||
$input = new ElementNode('input');
|
||||
$input->setAttribute('type', 'search');
|
||||
$input->setAttribute('id', $inputId);
|
||||
$input->setAttribute('name', $this->name);
|
||||
$input->setAttribute('class', 'admin-search__input');
|
||||
$input->setAttribute('placeholder', $this->placeholder);
|
||||
$input->setAttribute('aria-label', $ariaLabel);
|
||||
$input->setAttribute('autocomplete', $this->autocomplete ? 'on' : 'off');
|
||||
|
||||
if ($this->value !== null) {
|
||||
$input->setAttribute('value', $this->value);
|
||||
}
|
||||
|
||||
if ($this->ariaDescribedBy !== null) {
|
||||
$input->setAttribute('aria-describedby', $this->ariaDescribedBy);
|
||||
}
|
||||
|
||||
$form->appendChild($input);
|
||||
|
||||
// Submit button
|
||||
$button = new ElementNode('button');
|
||||
$button->setAttribute('type', 'submit');
|
||||
$button->setAttribute('class', 'admin-search__submit');
|
||||
$button->setAttribute('aria-label', 'Submit search');
|
||||
|
||||
// SVG icon
|
||||
$svg = new ElementNode('svg');
|
||||
$svg->setAttribute('class', 'admin-search__icon');
|
||||
$svg->setAttribute('fill', 'none');
|
||||
$svg->setAttribute('stroke', 'currentColor');
|
||||
$svg->setAttribute('viewBox', '0 0 24 24');
|
||||
$svg->setAttribute('aria-hidden', 'true');
|
||||
|
||||
$path = new ElementNode('path');
|
||||
$path->setAttribute('stroke-linecap', 'round');
|
||||
$path->setAttribute('stroke-linejoin', 'round');
|
||||
$path->setAttribute('stroke-width', '2');
|
||||
$path->setAttribute('d', 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z');
|
||||
|
||||
$svg->appendChild($path);
|
||||
$button->appendChild($svg);
|
||||
$form->appendChild($button);
|
||||
|
||||
$search->appendChild($form);
|
||||
|
||||
return $search;
|
||||
}
|
||||
|
||||
private function getSearchClass(): string
|
||||
{
|
||||
$baseClass = 'admin-search';
|
||||
$variantClass = "admin-search--{$this->variant}";
|
||||
return "{$baseClass} {$variantClass}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom;
|
||||
|
||||
/**
|
||||
* @deprecated Use App\Framework\View\ValueObjects\HtmlAttribute instead
|
||||
* This class is kept for backward compatibility but will be removed in a future version.
|
||||
*
|
||||
* Migration:
|
||||
* - Old: new Attribute('name', 'value')
|
||||
* - New: HtmlAttribute::withValue('name', 'value')
|
||||
* - Old: new Attribute('disabled', '') (flag attribute)
|
||||
* - New: HtmlAttribute::flag('disabled')
|
||||
*/
|
||||
final readonly class Attribute
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -4,11 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom;
|
||||
|
||||
use App\Framework\View\ValueObjects\DataAttributeHelper;
|
||||
use App\Framework\View\ValueObjects\DataAttributeInterface;
|
||||
use App\Framework\View\ValueObjects\HtmlAttribute;
|
||||
|
||||
final class ElementNode implements Node
|
||||
{
|
||||
use NodeTrait;
|
||||
|
||||
/** @var array<string, Attribute> */
|
||||
/** @var array<string, HtmlAttribute> */
|
||||
private array $attributes = [];
|
||||
|
||||
/** @var array<string> Self-closing HTML tags */
|
||||
@@ -36,28 +40,51 @@ final class ElementNode implements Node
|
||||
return $this->tagName;
|
||||
}
|
||||
|
||||
public function setAttribute(string $name, string $value): void
|
||||
public function setAttribute(string|DataAttributeInterface $name, ?string $value = null): void
|
||||
{
|
||||
$this->attributes[$name] = new Attribute($name, $value);
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
$this->attributes[$nameString] = $value === null
|
||||
? HtmlAttribute::flag($name)
|
||||
: HtmlAttribute::withValue($name, $value);
|
||||
}
|
||||
|
||||
public function getAttribute(string $name): ?string
|
||||
public function getAttribute(string|DataAttributeInterface $name): ?string
|
||||
{
|
||||
return $this->attributes[$name]->value ?? null;
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
|
||||
// Prüfe explizit, ob der Key existiert, bevor wir darauf zugreifen
|
||||
if (!isset($this->attributes[$nameString])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->attributes[$nameString]->value();
|
||||
}
|
||||
|
||||
public function hasAttribute(string $name): bool
|
||||
public function hasAttribute(string|DataAttributeInterface $name): bool
|
||||
{
|
||||
return isset($this->attributes[$name]);
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
return isset($this->attributes[$nameString]);
|
||||
}
|
||||
|
||||
public function removeAttribute(string $name): void
|
||||
public function removeAttribute(string|DataAttributeInterface $name): void
|
||||
{
|
||||
unset($this->attributes[$name]);
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
unset($this->attributes[$nameString]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Attribute>
|
||||
* Get attribute as HtmlAttribute object
|
||||
*/
|
||||
public function getAttributeObject(string|DataAttributeInterface $name): ?HtmlAttribute
|
||||
{
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
|
||||
// Prüfe explizit, ob der Key existiert
|
||||
return $this->attributes[$nameString] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, HtmlAttribute>
|
||||
*/
|
||||
public function getAttributes(): array
|
||||
{
|
||||
@@ -82,9 +109,8 @@ final class ElementNode implements Node
|
||||
{
|
||||
$cloned = new self($this->tagName);
|
||||
|
||||
foreach ($this->attributes as $attribute) {
|
||||
$cloned->setAttribute($attribute->name, $attribute->value);
|
||||
}
|
||||
// Directly copy attributes array (more efficient than calling setAttribute)
|
||||
$cloned->attributes = $this->attributes;
|
||||
|
||||
foreach ($this->children as $child) {
|
||||
$cloned->appendChild($child->clone());
|
||||
|
||||
@@ -12,4 +12,5 @@ enum NodeType: string
|
||||
case COMMENT = 'comment';
|
||||
case CDATA = 'cdata';
|
||||
case DOCTYPE = 'doctype';
|
||||
case RAW_HTML = 'raw-html';
|
||||
}
|
||||
|
||||
@@ -25,8 +25,23 @@ final class HtmlParser
|
||||
|
||||
public function parse(string $html): DocumentNode
|
||||
{
|
||||
// Debug: Log input HTML if it contains placeholders
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($html, '{{$item')) {
|
||||
error_log("HtmlParser::parse: Input HTML contains {{\$item: " . substr($html, 0, 200));
|
||||
}
|
||||
|
||||
$lexer = new HtmlLexer($html);
|
||||
$this->tokens = $lexer->tokenize();
|
||||
|
||||
// Debug: Log tokens if they contain placeholders
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
foreach ($this->tokens as $idx => $token) {
|
||||
if (str_contains($token->content, '{{')) {
|
||||
error_log("HtmlParser::parse: Token {$idx} ({$token->type->name}) contains placeholder: " . substr($token->content, 0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->position = 0;
|
||||
$this->openElements = [];
|
||||
|
||||
@@ -103,11 +118,16 @@ final class HtmlParser
|
||||
{
|
||||
$text = $token->content;
|
||||
|
||||
// Skip empty text nodes (only whitespace)
|
||||
if (trim($text) === '' && count($this->openElements) === 0) {
|
||||
// Skip empty text nodes (only whitespace) BUT only if they're outside any element
|
||||
// AND don't contain placeholders (which might be whitespace-sensitive)
|
||||
$hasPlaceholders = str_contains($text, '{{');
|
||||
$isOnlyWhitespace = trim($text) === '';
|
||||
|
||||
if ($isOnlyWhitespace && count($this->openElements) === 0 && !$hasPlaceholders) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always create TextNode if it contains placeholders or is inside an element
|
||||
$textNode = new TextNode($text);
|
||||
$parent = $this->getCurrentParent($document);
|
||||
$parent->appendChild($textNode);
|
||||
@@ -149,15 +169,99 @@ final class HtmlParser
|
||||
{
|
||||
// Parse HTML attributes with proper quote handling
|
||||
// Supports: attr="value", attr='value', attr=value, attr (boolean)
|
||||
|
||||
$pattern = '/([a-z][a-z0-9_:-]*)\s*(?:=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?/i';
|
||||
|
||||
preg_match_all($pattern, $attributesString, $matches, PREG_SET_ORDER);
|
||||
// IMPORTANT: Must handle nested quotes in placeholders like {{$item['url']}}
|
||||
|
||||
// Debug: Log attributes string if it contains placeholders
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($attributesString, '{{')) {
|
||||
error_log("HtmlParser::parseAttributes: Input attributesString: " . substr($attributesString, 0, 200));
|
||||
}
|
||||
|
||||
// First, protect placeholders by temporarily replacing them
|
||||
// This prevents the regex from breaking on nested quotes inside placeholders
|
||||
// Pattern matches: {{...}} and {{{...}}} (raw placeholders)
|
||||
// IMPORTANT: Use non-greedy matching with lookahead to handle nested structures
|
||||
$placeholders = [];
|
||||
$placeholderCounter = 0;
|
||||
$protectedString = preg_replace_callback(
|
||||
'/\{\{(\{?)(.*?)\}?\}\}/s',
|
||||
function ($matches) use (&$placeholders, &$placeholderCounter) {
|
||||
$key = '___PLACEHOLDER_' . $placeholderCounter++ . '___';
|
||||
$placeholders[$key] = $matches[0];
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("HtmlParser::parseAttributes: Protected placeholder {$key}: " . $matches[0]);
|
||||
}
|
||||
return $key;
|
||||
},
|
||||
$attributesString
|
||||
);
|
||||
|
||||
// Debug: Log protected string and placeholder count
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
if (str_contains($attributesString, '{{')) {
|
||||
error_log("HtmlParser::parseAttributes: Protected " . count($placeholders) . " placeholder(s)");
|
||||
error_log("HtmlParser::parseAttributes: Protected string: " . substr($protectedString, 0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// Now parse with standard pattern (placeholders are protected)
|
||||
// IMPORTANT: Exclude protected placeholders from being matched as attribute names
|
||||
// Pattern: attribute name (must start with letter, not PLACEHOLDER), optional = value
|
||||
$pattern = '/(?<!___)([a-z][a-z0-9_:-]*)\s*(?:=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?/i';
|
||||
preg_match_all($pattern, $protectedString, $matches, PREG_SET_ORDER);
|
||||
|
||||
// Filter out matches that are actually protected placeholders
|
||||
// Protected placeholders look like: ___PLACEHOLDER_0___ (in values) or PLACEHOLDER_0___ (as attribute names)
|
||||
// But they might be parsed as attribute names if they appear in the wrong context
|
||||
// Also check for LACEHOLDER_X___ (without leading ___) which can appear when placeholders are not properly protected
|
||||
$matches = array_filter($matches, function($match) {
|
||||
$attrName = $match[1];
|
||||
// Skip if this looks like a protected placeholder key
|
||||
// Pattern: PLACEHOLDER_ or LACEHOLDER_ followed by digits and ___
|
||||
if (preg_match('/^(P|L)LACEHOLDER_\d+___/', $attrName)) {
|
||||
return false;
|
||||
}
|
||||
// Also skip if it starts with LACEHOLDER_ (without P) - this is a malformed placeholder
|
||||
if (str_starts_with($attrName, 'LACEHOLDER_') && preg_match('/^LACEHOLDER_\d+___/', $attrName)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$name = $match[1];
|
||||
$value = $match[2] ?? $match[3] ?? $match[4] ?? '';
|
||||
|
||||
|
||||
// Restore placeholders in the value
|
||||
foreach ($placeholders as $key => $placeholder) {
|
||||
$value = str_replace($key, $placeholder, $value);
|
||||
}
|
||||
|
||||
// Validate that all protected placeholders were restored
|
||||
// This prevents bugs where placeholders are not properly restored
|
||||
foreach ($placeholders as $key => $placeholder) {
|
||||
if (str_contains($value, $key)) {
|
||||
$errorMsg = sprintf(
|
||||
"HtmlParser::parseAttributes: CRITICAL BUG - Protected placeholder '%s' was not restored in attribute '%s'. Original value: %s",
|
||||
$key,
|
||||
$name,
|
||||
substr($value, 0, 100)
|
||||
);
|
||||
error_log($errorMsg);
|
||||
// Try to restore it manually as a fallback
|
||||
$value = str_replace($key, $placeholder, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log attribute value if it contains placeholders
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
if (str_contains($value, '{{')) {
|
||||
error_log("HtmlParser::parseAttributes: Setting attribute '{$name}' = " . substr($value, 0, 100));
|
||||
}
|
||||
if (!empty($placeholders)) {
|
||||
error_log("HtmlParser::parseAttributes: Processed " . count($placeholders) . " protected placeholders for attribute '{$name}'");
|
||||
}
|
||||
}
|
||||
|
||||
$element->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
68
src/Framework/View/Dom/RawHtmlNode.php
Normal file
68
src/Framework/View/Dom/RawHtmlNode.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom;
|
||||
|
||||
/**
|
||||
* RawHtmlNode - Repräsentiert bereits verarbeitetes Raw HTML im AST
|
||||
*
|
||||
* Verhindert, dass Raw HTML später nochmal escaped oder verarbeitet wird.
|
||||
* Wird verwendet, wenn {{{ }}} Platzhalter zu HTML-Strings aufgelöst werden.
|
||||
*/
|
||||
final class RawHtmlNode implements Node
|
||||
{
|
||||
use NodeTrait {
|
||||
getTextContent as private getTextContentFromChildren;
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private string $html
|
||||
) {}
|
||||
|
||||
public function getNodeType(): NodeType
|
||||
{
|
||||
return NodeType::RAW_HTML;
|
||||
}
|
||||
|
||||
public function getNodeName(): string
|
||||
{
|
||||
return '#raw-html';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Raw HTML-String zurück
|
||||
*/
|
||||
public function getHtml(): string
|
||||
{
|
||||
return $this->html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den HTML-String zurück (für Kompatibilität mit Node-Interface)
|
||||
*/
|
||||
public function getTextContent(): string
|
||||
{
|
||||
return $this->html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den HTML-String
|
||||
*/
|
||||
public function setHtml(string $html): void
|
||||
{
|
||||
$this->html = $html;
|
||||
}
|
||||
|
||||
public function accept(NodeVisitor $visitor): void
|
||||
{
|
||||
// RawHtmlNode wird nicht von normalen Visitors verarbeitet
|
||||
// Falls nötig, kann hier eine spezielle Visitor-Methode aufgerufen werden
|
||||
}
|
||||
|
||||
public function clone(): Node
|
||||
{
|
||||
return new self($this->html);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Framework\View\Dom\CommentNode;
|
||||
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;
|
||||
|
||||
final class HtmlRenderer
|
||||
@@ -33,6 +34,7 @@ final class HtmlRenderer
|
||||
DocumentNode::class => $this->renderDocument($node),
|
||||
ElementNode::class => $this->renderElement($node),
|
||||
TextNode::class => $this->renderText($node),
|
||||
RawHtmlNode::class => $this->renderRawHtml($node),
|
||||
CommentNode::class => $this->renderComment($node),
|
||||
default => '',
|
||||
};
|
||||
@@ -128,6 +130,12 @@ final class HtmlRenderer
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function renderRawHtml(RawHtmlNode $node): string
|
||||
{
|
||||
// Raw HTML wird direkt ausgegeben, ohne weitere Verarbeitung oder Escaping
|
||||
return $node->getHtml();
|
||||
}
|
||||
|
||||
private function renderComment(CommentNode $node): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom\Transformer;
|
||||
|
||||
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\RenderContext;
|
||||
|
||||
/**
|
||||
* BooleanAttributeTransformer
|
||||
*
|
||||
* Removes boolean HTML attributes when their value is "false".
|
||||
* HTML boolean attributes should be present (as flags) or absent, not set to "false".
|
||||
*
|
||||
* Handles: selected, checked, disabled, readonly, required, multiple, autofocus, etc.
|
||||
*/
|
||||
final readonly class BooleanAttributeTransformer implements AstTransformer
|
||||
{
|
||||
/**
|
||||
* List of boolean HTML attributes that should be removed when set to "false"
|
||||
*/
|
||||
private const array BOOLEAN_ATTRIBUTES = [
|
||||
'selected',
|
||||
'checked',
|
||||
'disabled',
|
||||
'readonly',
|
||||
'required',
|
||||
'multiple',
|
||||
'autofocus',
|
||||
'autoplay',
|
||||
'controls',
|
||||
'loop',
|
||||
'muted',
|
||||
'open',
|
||||
'reversed',
|
||||
'scoped',
|
||||
'seamless',
|
||||
'async',
|
||||
'defer',
|
||||
'hidden',
|
||||
'ismap',
|
||||
'itemscope',
|
||||
'novalidate',
|
||||
'pubdate',
|
||||
'spellcheck',
|
||||
'translate',
|
||||
];
|
||||
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
{
|
||||
$this->processNode($document, $context);
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function processNode(Node $node, RenderContext $context): void
|
||||
{
|
||||
if ($node instanceof ElementNode) {
|
||||
// Remove boolean attributes with falsy values
|
||||
foreach (self::BOOLEAN_ATTRIBUTES as $attr) {
|
||||
if ($node->hasAttribute($attr)) {
|
||||
$value = $node->getAttribute($attr);
|
||||
// Check for falsy values:
|
||||
// - String "false" (with or without quotes)
|
||||
// - Empty string (boolean false becomes "" when converted to string)
|
||||
// - String "0" (numeric zero)
|
||||
// - Boolean false
|
||||
// - null (shouldn't happen, but check anyway)
|
||||
if ($value === 'false'
|
||||
|| $value === false
|
||||
|| $value === '0'
|
||||
|| $value === ''
|
||||
|| $value === null) {
|
||||
$node->removeAttribute($attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$this->processNode($child, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,32 @@ 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\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\TextNode;
|
||||
use App\Framework\View\Processors\PlaceholderReplacer;
|
||||
use App\Framework\View\Exceptions\PlaceholderException;
|
||||
use App\Framework\View\RenderContext;
|
||||
|
||||
/**
|
||||
* ForTransformer - AST-based foreach loop processor using PlaceholderProcessor
|
||||
* ForTransformer - AST-based foreach loop processor with scope management
|
||||
*
|
||||
* Processes:
|
||||
* - foreach attributes: <div foreach="$items as $item">
|
||||
* - <for> elements: <for items="items" as="item">
|
||||
*
|
||||
* Uses PlaceholderProcessor for consistent placeholder replacement with ExpressionEvaluator
|
||||
* Creates scopes for loop variables that PlaceholderTransformer can use.
|
||||
*/
|
||||
final readonly class ForTransformer implements AstTransformer
|
||||
{
|
||||
private PlaceholderProcessor $placeholderProcessor;
|
||||
private ExpressionEvaluator $evaluator;
|
||||
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
$this->placeholderProcessor = new PlaceholderProcessor();
|
||||
$this->evaluator = new ExpressionEvaluator();
|
||||
}
|
||||
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
@@ -43,20 +42,23 @@ final readonly class ForTransformer implements AstTransformer
|
||||
private function processForLoops(Node $node, RenderContext $context): void
|
||||
{
|
||||
if (!$node instanceof ElementNode && !$node instanceof DocumentNode) {
|
||||
return;
|
||||
return; // TextNodes werden in processPlaceholdersInAllNodes verarbeitet
|
||||
}
|
||||
|
||||
// Process children first (depth-first for nested loops)
|
||||
// Process foreach attribute on this element FIRST (before processing children)
|
||||
// This prevents nested foreach from being processed before loop variables are available
|
||||
if ($node instanceof ElementNode && $node->hasAttribute('foreach')) {
|
||||
$this->processForeachAttribute($node, $context);
|
||||
return; // After processing foreach, the node is replaced, so don't process children
|
||||
}
|
||||
|
||||
// Process children (depth-first for nested loops)
|
||||
// Only process children if this element doesn't have a foreach attribute
|
||||
$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);
|
||||
@@ -65,27 +67,59 @@ final readonly class ForTransformer implements AstTransformer
|
||||
|
||||
/**
|
||||
* Process foreach attribute: <div foreach="$items as $item">
|
||||
* Supports nested paths: <div foreach="$stack.containers as $container">
|
||||
*/
|
||||
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)) {
|
||||
// Supports nested paths like: $stack.containers as $container
|
||||
// Pattern: optional $, then word chars or dots, then "as", then optional $, then word chars
|
||||
if (!preg_match('/^\$?([\w.]+)\s+as\s+\$?(\w+)$/', $foreachExpr, $matches)) {
|
||||
return; // Invalid syntax
|
||||
}
|
||||
|
||||
$dataKey = $matches[1];
|
||||
$varName = $matches[2];
|
||||
|
||||
// Remove foreach attribute
|
||||
// Remove foreach attribute BEFORE cloning (so nested foreach won't be processed yet)
|
||||
$node->removeAttribute('foreach');
|
||||
|
||||
// Resolve items from context
|
||||
$items = $this->resolveValue($context->data, $dataKey);
|
||||
// Process placeholders in dataKey first (e.g., {{$pagination['pages']}} -> actual array)
|
||||
// PlaceholderTransformer may have already processed this, so check if it's already an array
|
||||
$processedDataKey = $dataKey;
|
||||
|
||||
// If dataKey contains placeholders, process them
|
||||
if (str_contains($dataKey, '{{')) {
|
||||
$processedDataKey = $this->processPlaceholders($dataKey, $context);
|
||||
}
|
||||
|
||||
// If processed value is already an array/iterable, use it directly
|
||||
// Otherwise, try to resolve it as a path expression
|
||||
if (is_iterable($processedDataKey)) {
|
||||
$items = $processedDataKey;
|
||||
} else {
|
||||
// Resolve items from context (includes scopes) using path expression
|
||||
$items = $this->resolveValue($context, $processedDataKey);
|
||||
}
|
||||
|
||||
// Debug: Log items resolution
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::processForeachAttribute: dataKey: '{$dataKey}', processedDataKey type: " . gettype($processedDataKey));
|
||||
error_log("ForTransformer::processForeachAttribute: items type: " . gettype($items));
|
||||
if (is_iterable($items)) {
|
||||
error_log("ForTransformer::processForeachAttribute: items count: " . (is_countable($items) ? count($items) : 'N/A'));
|
||||
} else {
|
||||
error_log("ForTransformer::processForeachAttribute: items is NOT iterable!");
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_iterable($items)) {
|
||||
// Remove element if not iterable
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::processForeachAttribute: Removing element because items is not iterable");
|
||||
}
|
||||
$parent = $node->getParent();
|
||||
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
|
||||
$parent->removeChild($node);
|
||||
@@ -102,10 +136,71 @@ final readonly class ForTransformer implements AstTransformer
|
||||
// Clone and process for each item
|
||||
$fragments = [];
|
||||
foreach ($items as $item) {
|
||||
// Debug: Log original node structure BEFORE cloning
|
||||
if (getenv('APP_DEBUG') === 'true' && $node instanceof ElementNode) {
|
||||
$children = $node->getChildren();
|
||||
error_log("ForTransformer::processForeachAttribute: Original node has " . count($children) . " children");
|
||||
foreach ($children as $idx => $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$grandChildren = $child->getChildren();
|
||||
error_log("ForTransformer::processForeachAttribute: Original Child {$idx} (<{$child->getTagName()}>) has " . count($grandChildren) . " children");
|
||||
foreach ($grandChildren as $gIdx => $grandChild) {
|
||||
if ($grandChild instanceof TextNode) {
|
||||
$text = $grandChild->getTextContent();
|
||||
error_log("ForTransformer::processForeachAttribute: Original GrandChild {$gIdx} (TextNode) content: " . substr($text, 0, 100));
|
||||
}
|
||||
}
|
||||
} elseif ($child instanceof TextNode) {
|
||||
$text = $child->getTextContent();
|
||||
error_log("ForTransformer::processForeachAttribute: Original Child {$idx} (TextNode) content: " . substr($text, 0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$clone = $node->clone();
|
||||
|
||||
// Process placeholders in cloned element
|
||||
$this->replacePlaceholdersInNode($clone, $varName, $item);
|
||||
// Create a new context with the loop variable in a scope
|
||||
// PlaceholderTransformer will process placeholders later using this scope
|
||||
$loopContext = $context->withScope([$varName => $item]);
|
||||
|
||||
// Debug: Log scope creation
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
$allVars = $loopContext->getAllVariables();
|
||||
error_log("ForTransformer::processForeachAttribute: Created scope for '{$varName}'");
|
||||
error_log("ForTransformer::processForeachAttribute: Available variables: " . implode(', ', array_keys($allVars)));
|
||||
if (isset($allVars[$varName])) {
|
||||
error_log("ForTransformer::processForeachAttribute: Item type: " . gettype($allVars[$varName]));
|
||||
if (is_array($allVars[$varName])) {
|
||||
error_log("ForTransformer::processForeachAttribute: Item keys: " . implode(', ', array_keys($allVars[$varName])));
|
||||
}
|
||||
}
|
||||
// Debug: Log clone structure
|
||||
$children = $clone->getChildren();
|
||||
error_log("ForTransformer::processForeachAttribute: Clone has " . count($children) . " children");
|
||||
foreach ($children as $idx => $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$grandChildren = $child->getChildren();
|
||||
error_log("ForTransformer::processForeachAttribute: Child {$idx} (<{$child->getTagName()}>) has " . count($grandChildren) . " children");
|
||||
foreach ($grandChildren as $gIdx => $grandChild) {
|
||||
if ($grandChild instanceof TextNode) {
|
||||
$text = $grandChild->getTextContent();
|
||||
error_log("ForTransformer::processForeachAttribute: GrandChild {$gIdx} (TextNode) content: " . substr($text, 0, 100));
|
||||
}
|
||||
}
|
||||
} elseif ($child instanceof TextNode) {
|
||||
$text = $child->getTextContent();
|
||||
error_log("ForTransformer::processForeachAttribute: Child {$idx} (TextNode) content: " . substr($text, 0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested foreach attributes (creates nested scopes)
|
||||
$this->processForLoops($clone, $loopContext);
|
||||
|
||||
// Process placeholders in ALL nodes of the clone (including TextNodes within ElementNodes)
|
||||
// This must happen AFTER processForLoops to ensure nested loops are expanded first
|
||||
// IMPORTANT: This must happen BEFORE PlaceholderTransformer runs, so we process placeholders here
|
||||
$this->processPlaceholdersInAllNodes($clone, $loopContext);
|
||||
|
||||
$fragments[] = $clone;
|
||||
}
|
||||
@@ -131,10 +226,30 @@ final readonly class ForTransformer implements AstTransformer
|
||||
return; // Invalid syntax
|
||||
}
|
||||
|
||||
// Resolve items from context
|
||||
$items = $this->resolveValue($context->data, $dataKey);
|
||||
// Resolve items from context (includes scopes)
|
||||
// Handle both placeholder syntax ({{$pagination['pages']}}) and direct path (pagination.pages)
|
||||
$items = null;
|
||||
|
||||
// If dataKey contains placeholders, resolve them first
|
||||
if (str_contains($dataKey, '{{')) {
|
||||
// Extract expression from placeholder: {{$pagination['pages']}} -> $pagination['pages']
|
||||
if (preg_match('/{{\\s*\\$?([^}]+?)\\s*}}/', $dataKey, $matches)) {
|
||||
$expr = trim($matches[1]);
|
||||
$items = $this->resolveExpression($context, $expr);
|
||||
}
|
||||
}
|
||||
|
||||
// If not resolved yet, try as path expression
|
||||
if ($items === null || !is_iterable($items)) {
|
||||
$items = $this->resolveValue($context, $dataKey);
|
||||
}
|
||||
|
||||
if (!is_iterable($items)) {
|
||||
// Debug: Log why items are not iterable
|
||||
$itemsType = gettype($items);
|
||||
$itemsValue = is_string($items) ? substr($items, 0, 100) : (is_scalar($items) ? (string)$items : gettype($items));
|
||||
error_log("ForTransformer: items not iterable. Type: {$itemsType}, Value: {$itemsValue}, dataKey: {$dataKey}");
|
||||
|
||||
// Remove element if not iterable
|
||||
$parent = $node->getParent();
|
||||
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
|
||||
@@ -155,8 +270,15 @@ final readonly class ForTransformer implements AstTransformer
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$clone = $child->clone();
|
||||
|
||||
// Process placeholders
|
||||
$this->replacePlaceholdersInNode($clone, $varName, $item);
|
||||
// Create scope for loop variable
|
||||
// PlaceholderTransformer will process placeholders later
|
||||
$loopContext = $context->withScope([$varName => $item]);
|
||||
|
||||
// Process nested foreach attributes
|
||||
$this->processForLoops($clone, $loopContext);
|
||||
|
||||
// Process placeholders in ALL nodes of the clone
|
||||
$this->processPlaceholdersInAllNodes($clone, $loopContext);
|
||||
|
||||
$fragments[] = $clone;
|
||||
}
|
||||
@@ -171,52 +293,54 @@ final readonly class ForTransformer implements AstTransformer
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace placeholders in a node and its children using PlaceholderProcessor
|
||||
* Resolve expression with array access support: $pagination['pages'] or pagination['pages']
|
||||
*/
|
||||
private function replacePlaceholdersInNode(Node $node, string $varName, mixed $item): void
|
||||
private function resolveExpression(RenderContext $context, string $expr): mixed
|
||||
{
|
||||
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);
|
||||
$allVariables = $context->getAllVariables();
|
||||
|
||||
// Handle array access: $pagination['pages'] or pagination['pages']
|
||||
if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[[\'"]([^\'"]+)[\'"]\\]$/', $expr, $matches)) {
|
||||
$varName = $matches[1];
|
||||
$key = $matches[2];
|
||||
|
||||
if (isset($allVariables[$varName]) && is_array($allVariables[$varName])) {
|
||||
return $allVariables[$varName][$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dot notation: pagination.pages
|
||||
return $this->resolveValue($context, $expr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve nested property paths like "redis.key_sample"
|
||||
* Resolve nested property paths like "redis.key_sample" or "stack.containers"
|
||||
* Uses context's getAllVariables() to include scopes
|
||||
*/
|
||||
private function resolveValue(array $data, string $expr): mixed
|
||||
private function resolveValue(RenderContext $context, string $expr): mixed
|
||||
{
|
||||
// Get all variables including scopes
|
||||
$data = $context->getAllVariables();
|
||||
|
||||
// Debug: Log resolution attempt
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::resolveValue: Resolving '{$expr}'");
|
||||
error_log("ForTransformer::resolveValue: Available variables: " . implode(', ', array_keys($data)));
|
||||
}
|
||||
|
||||
$keys = explode('.', $expr);
|
||||
$value = $data;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
// Debug: Log successful resolution
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::resolveValue: Found key '{$key}', type: " . gettype($value));
|
||||
if (is_iterable($value)) {
|
||||
error_log("ForTransformer::resolveValue: Value is iterable, count: " . (is_countable($value) ? count($value) : 'N/A'));
|
||||
}
|
||||
}
|
||||
} elseif (is_object($value)) {
|
||||
if (isset($value->$key)) {
|
||||
$value = $value->$key;
|
||||
@@ -225,14 +349,427 @@ final readonly class ForTransformer implements AstTransformer
|
||||
} elseif (method_exists($value, 'get' . ucfirst($key))) {
|
||||
$getterMethod = 'get' . ucfirst($key);
|
||||
$value = $value->$getterMethod();
|
||||
} elseif (method_exists($value, 'toArray')) {
|
||||
// Try toArray() for Value Objects
|
||||
$array = $value->toArray();
|
||||
if (is_array($array) && array_key_exists($key, $array)) {
|
||||
$value = $array[$key];
|
||||
} else {
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::resolveValue: Key '{$key}' not found in object array");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::resolveValue: Key '{$key}' not found in object");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::resolveValue: Cannot access key '{$key}' on non-array/object (type: " . gettype($value) . ")");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in ALL nodes recursively, including TextNodes within ElementNodes
|
||||
* This ensures loop variables from scopes are available when processing placeholders
|
||||
*
|
||||
* This method is called AFTER processForLoops to ensure nested loops are expanded first
|
||||
*/
|
||||
private function processPlaceholdersInAllNodes(Node $node, RenderContext $context): void
|
||||
{
|
||||
if ($node instanceof TextNode) {
|
||||
$text = $node->getTextContent();
|
||||
// Only process if text contains placeholders
|
||||
if (str_contains($text, '{{')) {
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("[Placeholder] ForTransformer::processPlaceholdersInAllNodes: Found placeholder in TextNode: " . substr($text, 0, 100));
|
||||
$allVars = $context->getAllVariables();
|
||||
error_log("[Placeholder] ForTransformer::processPlaceholdersInAllNodes: Available variables: " . implode(', ', array_keys($allVars)));
|
||||
}
|
||||
$processed = $this->processPlaceholders($text, $context);
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("[Placeholder] ForTransformer::processPlaceholdersInAllNodes: Processed TextNode result: " . substr($processed, 0, 100));
|
||||
}
|
||||
$node->setText($processed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($node instanceof ElementNode) {
|
||||
// Debug: Log element being processed
|
||||
if (getenv('APP_DEBUG') === 'true' && ($node->getTagName() === 'a' || $node->getTagName() === 'li')) {
|
||||
error_log("ForTransformer::processPlaceholdersInAllNodes: Processing <{$node->getTagName()}> element with " . count($node->getChildren()) . " children");
|
||||
}
|
||||
|
||||
// Process attributes FIRST
|
||||
foreach (array_keys($node->getAttributes()) as $attrName) {
|
||||
$attrValue = $node->getAttribute($attrName);
|
||||
if ($attrValue !== null) {
|
||||
$decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Check for protected placeholders first (this is a bug - HtmlParser should have restored them)
|
||||
// If we find them, we can't process them because we don't have access to the original placeholder value
|
||||
if (preg_match('/___PLACEHOLDER_(\d+)___/', $decodedValue, $protectedMatches)) {
|
||||
// This is a critical bug - HtmlParser should have restored the placeholder
|
||||
// Log it as an error and skip this attribute
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::processPlaceholdersInAllNodes: CRITICAL BUG - Found protected placeholder '{$protectedMatches[0]}' in attribute '{$attrName}'");
|
||||
error_log("ForTransformer::processPlaceholdersInAllNodes: This indicates HtmlParser did not restore the placeholder correctly");
|
||||
error_log("ForTransformer::processPlaceholdersInAllNodes: Attribute value: " . substr($decodedValue, 0, 100));
|
||||
error_log("ForTransformer::processPlaceholdersInAllNodes: Skipping attribute to avoid processing invalid data");
|
||||
}
|
||||
// Skip this attribute - it's malformed and we can't process it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for standard placeholders ({{...}})
|
||||
// HtmlParser should have already restored protected placeholders, so we should only see {{...}}
|
||||
$hasPlaceholder = str_contains($decodedValue, '{{');
|
||||
|
||||
if ($hasPlaceholder) {
|
||||
// Debug: Log placeholder found in attribute
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::processPlaceholdersInAllNodes: Found placeholder in attribute '{$attrName}': " . substr($decodedValue, 0, 100));
|
||||
}
|
||||
|
||||
// For href attributes, URLs should not be escaped (they will be escaped by setAttribute)
|
||||
if ($attrName === 'href') {
|
||||
$processedValue = $this->processPlaceholdersRaw($decodedValue, $context);
|
||||
} else {
|
||||
$processedValue = $this->processPlaceholders($decodedValue, $context);
|
||||
}
|
||||
|
||||
// Debug: Log processed value
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("ForTransformer::processPlaceholdersInAllNodes: Processed value for '{$attrName}': " . substr((string)$processedValue, 0, 100));
|
||||
}
|
||||
|
||||
$node->setAttribute($attrName, $processedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process children recursively (including TextNodes)
|
||||
$children = $node->getChildren();
|
||||
foreach ($children as $child) {
|
||||
$this->processPlaceholdersInAllNodes($child, $context);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process children for DocumentNode and other container nodes
|
||||
$children = $node->getChildren();
|
||||
foreach ($children as $child) {
|
||||
$this->processPlaceholdersInAllNodes($child, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in a string using the context's variables (including scopes)
|
||||
* Uses ExpressionEvaluator for consistent evaluation
|
||||
*/
|
||||
private function processPlaceholders(string $html, RenderContext $context): string
|
||||
{
|
||||
$allVariables = $context->getAllVariables();
|
||||
$debugMode = getenv('APP_DEBUG') === 'true';
|
||||
|
||||
// Simple placeholder replacement: {{ expression }}
|
||||
return preg_replace_callback(
|
||||
'/{{\\s*([^}]+?)\\s*}}/',
|
||||
function ($matches) use ($allVariables, $debugMode) {
|
||||
$expression = trim($matches[1]);
|
||||
|
||||
try {
|
||||
// Validate expression for security
|
||||
$this->validateExpression($expression);
|
||||
|
||||
// Handle fallback syntax: {{ $var ?? 'default' }}
|
||||
if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $fallbackMatches)) {
|
||||
$primaryExpression = trim($fallbackMatches[1]);
|
||||
$fallbackExpression = trim($fallbackMatches[2]);
|
||||
|
||||
// Try primary expression first
|
||||
try {
|
||||
$this->validateExpression($primaryExpression);
|
||||
$value = $this->evaluator->evaluate($primaryExpression, $allVariables);
|
||||
|
||||
if ($value !== null) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] SUCCESS: {$primaryExpression} -> " . $this->formatValueForLog($value) . " (" . gettype($value) . ")");
|
||||
}
|
||||
return $this->formatValue($value, false);
|
||||
}
|
||||
} 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->formatValue($fallbackValue, false);
|
||||
}
|
||||
|
||||
// Evaluate expression using ExpressionEvaluator
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
|
||||
if ($value === null) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] FAILED: {$expression} - Variable not found or returned null. Available: " . implode(', ', array_keys($allVariables)));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] SUCCESS: {$expression} -> " . $this->formatValueForLog($value) . " (" . gettype($value) . ")");
|
||||
}
|
||||
|
||||
return $this->formatValue($value, false);
|
||||
|
||||
} catch (PlaceholderException $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] ERROR: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
// In production, return empty string to avoid breaking templates
|
||||
return '';
|
||||
} catch (\Throwable $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] EXCEPTION: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
// In production, return empty string to avoid breaking templates
|
||||
return '';
|
||||
}
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for output, handling arrays and objects correctly
|
||||
*/
|
||||
private function formatValue(mixed $value, bool $isNegation): string
|
||||
{
|
||||
// Handle negation (boolean conversion) - for boolean, null, and scalar values
|
||||
if ($isNegation) {
|
||||
// For boolean values: invert
|
||||
if (is_bool($value)) {
|
||||
$value = !$value;
|
||||
}
|
||||
// For null: treat as false, so !null becomes true
|
||||
elseif ($value === null) {
|
||||
$value = true;
|
||||
}
|
||||
// For scalar values: convert to boolean and invert
|
||||
elseif (is_scalar($value)) {
|
||||
$value = !$value;
|
||||
}
|
||||
// For arrays: empty array is falsey, non-empty is truthy
|
||||
elseif (is_array($value)) {
|
||||
$value = empty($value);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (is_array($value)) {
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
// For arrays with only one element: return the element
|
||||
if (count($value) === 1 && !is_array(reset($value)) && !is_object(reset($value))) {
|
||||
return htmlspecialchars((string) reset($value), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
// For simple arrays: show count
|
||||
return '[' . count($value) . ' items]';
|
||||
}
|
||||
|
||||
// Handle null values
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Handle objects without __toString
|
||||
if (is_object($value) && !method_exists($value, '__toString')) {
|
||||
$className = get_class($value);
|
||||
$shortName = substr($className, strrpos($className, '\\') + 1);
|
||||
return '[' . $shortName . ']';
|
||||
}
|
||||
|
||||
// Handle scalar values and objects with __toString
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in a string without escaping (for URLs in href attributes)
|
||||
* Uses ExpressionEvaluator for consistent evaluation
|
||||
*/
|
||||
private function processPlaceholdersRaw(string $html, RenderContext $context): string
|
||||
{
|
||||
$allVariables = $context->getAllVariables();
|
||||
$debugMode = getenv('APP_DEBUG') === 'true';
|
||||
|
||||
// Process both {{{ }}} and {{ }} placeholders, but don't escape the result
|
||||
// First handle {{{ }}} placeholders
|
||||
$html = preg_replace_callback(
|
||||
'/{{{\\s*([^}]+?)\\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) {
|
||||
return (string) $value;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Use fallback
|
||||
}
|
||||
|
||||
$fallbackValue = $this->evaluateLiteral($fallbackExpression);
|
||||
return (string) $fallbackValue;
|
||||
}
|
||||
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
return $value !== null ? (string) $value : '';
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] RAW ERROR: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
// Then handle {{ }} placeholders (but don't escape)
|
||||
return preg_replace_callback(
|
||||
'/{{\\s*([^}]+?)\\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) {
|
||||
return (string) $value;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Use fallback
|
||||
}
|
||||
|
||||
$fallbackValue = $this->evaluateLiteral($fallbackExpression);
|
||||
return (string) $fallbackValue;
|
||||
}
|
||||
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
return $value !== null ? (string) $value : '';
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] RAW ERROR: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
530
src/Framework/View/Dom/Transformer/FormTransformer.php
Normal file
530
src/Framework/View/Dom/Transformer/FormTransformer.php
Normal file
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom\Transformer;
|
||||
|
||||
use App\Framework\Http\Session\FormIdGenerator;
|
||||
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\Parser\HtmlParser;
|
||||
use App\Framework\View\Dom\RawHtmlNode;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\View\ValueObjects\FormId;
|
||||
use App\Framework\View\ValueObjects\FormDataAttribute;
|
||||
use App\Framework\View\ValueObjects\LiveComponentCoreAttribute;
|
||||
|
||||
/**
|
||||
* FormTransformer - Adds form placeholders for FormDataResponseProcessor
|
||||
*
|
||||
* Processes forms in the AST and adds placeholders that FormDataResponseProcessor
|
||||
* (in the View/Response module) will replace with actual values at response time:
|
||||
* - CSRF token placeholder (___TOKEN_FORMID___)
|
||||
* - Form ID field (_form_id)
|
||||
* - Old input placeholders (___OLD_INPUT_FORMID_FIELDNAME___)
|
||||
* - Error display placeholders (___ERROR_FORMID_FIELDNAME___)
|
||||
*
|
||||
* Placeholders include form ID to ensure unique association with specific forms.
|
||||
*
|
||||
* Framework Pattern: readonly class, AST-based transformation
|
||||
*/
|
||||
final readonly class FormTransformer implements AstTransformer
|
||||
{
|
||||
public function __construct(
|
||||
private FormIdGenerator $formIdGenerator,
|
||||
private HtmlParser $parser
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
{
|
||||
// Find all form elements and add placeholders
|
||||
$this->processForms($document, $context);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find and process form elements
|
||||
*/
|
||||
private function processForms(Node $node, RenderContext $context): void
|
||||
{
|
||||
// Process RawHtmlNode - parse HTML and process forms inside
|
||||
if ($node instanceof RawHtmlNode) {
|
||||
$this->processRawHtmlNode($node, $context);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($node instanceof ElementNode) {
|
||||
// Check if this is a form element
|
||||
if (strtolower($node->getTagName()) === 'form') {
|
||||
// Skip forms inside LiveComponents (they use data-csrf-token instead)
|
||||
if ($this->isInsideLiveComponent($node)) {
|
||||
// Still add old input and error placeholders, but skip CSRF token
|
||||
$formId = $this->generateFormIdFromElement($node, $context);
|
||||
$this->addOldInputPlaceholders($node, $formId);
|
||||
$this->addErrorDisplayPlaceholders($node, $formId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate form ID from form action and method
|
||||
$formId = $this->generateFormIdFromElement($node, $context);
|
||||
|
||||
// 1. Add form ID first (ensures it appears before token)
|
||||
$this->addFormId($node, $formId);
|
||||
|
||||
// 2. Add CSRF token placeholder (after form ID) with form ID
|
||||
$this->addCsrfTokenPlaceholder($node, $formId);
|
||||
|
||||
// 3. Add old input placeholders to form fields with form ID
|
||||
$this->addOldInputPlaceholders($node, $formId);
|
||||
|
||||
// 4. Add error display placeholders with form ID
|
||||
$this->addErrorDisplayPlaceholders($node, $formId);
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into children for both ElementNode and DocumentNode
|
||||
if ($node instanceof ElementNode || $node instanceof DocumentNode) {
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$this->processForms($child, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process RawHtmlNode - parse HTML, process forms, and update the node
|
||||
*/
|
||||
private function processRawHtmlNode(RawHtmlNode $node, RenderContext $context): void
|
||||
{
|
||||
$html = $node->getHtml();
|
||||
|
||||
// Check if HTML contains forms
|
||||
if (!str_contains($html, '<form')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse HTML to AST
|
||||
$parsed = $this->parser->parse($html);
|
||||
|
||||
// Process forms in parsed AST
|
||||
$this->processForms($parsed, $context);
|
||||
|
||||
// Render back to HTML
|
||||
$renderer = new \App\Framework\View\Dom\Renderer\HtmlRenderer();
|
||||
$processedHtml = $renderer->render($parsed);
|
||||
|
||||
// Update RawHtmlNode with processed HTML
|
||||
$node->setHtml($processedHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CSRF token placeholder to form with form ID
|
||||
*/
|
||||
private function addCsrfTokenPlaceholder(ElementNode $form, FormId $formId): void
|
||||
{
|
||||
$formIdString = (string) $formId;
|
||||
$placeholder = "___TOKEN_{$formIdString}___";
|
||||
|
||||
// Check if CSRF token already exists
|
||||
$existing = $this->findFieldByName($form, '_token');
|
||||
if ($existing !== null) {
|
||||
// Update existing token to placeholder with form ID
|
||||
$existing->setAttribute('value', $placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSRF token input with placeholder
|
||||
$csrf = new ElementNode('input');
|
||||
$csrf->setAttribute('type', 'hidden');
|
||||
$csrf->setAttribute('name', '_token');
|
||||
$csrf->setAttribute('value', $placeholder);
|
||||
|
||||
// Insert at beginning of form
|
||||
$this->prependChild($form, $csrf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add form ID field to form
|
||||
*/
|
||||
private function addFormId(ElementNode $form, FormId $formId): void
|
||||
{
|
||||
// Check if form ID already exists
|
||||
$existing = $this->findFieldByName($form, '_form_id');
|
||||
if ($existing !== null) {
|
||||
// Update existing form ID
|
||||
$existing->setAttribute('value', (string) $formId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create form ID input
|
||||
$formIdField = new ElementNode('input');
|
||||
$formIdField->setAttribute('type', 'hidden');
|
||||
$formIdField->setAttribute('name', '_form_id');
|
||||
$formIdField->setAttribute('value', (string) $formId);
|
||||
|
||||
// Insert at beginning of form
|
||||
$this->prependChild($form, $formIdField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add old input placeholders to form fields with form ID
|
||||
*/
|
||||
private function addOldInputPlaceholders(ElementNode $form, FormId $formId): void
|
||||
{
|
||||
$formIdString = (string) $formId;
|
||||
|
||||
// Text inputs, email, password, etc.
|
||||
$this->addOldInputToTextInputs($form, $formIdString);
|
||||
|
||||
// Textareas
|
||||
$this->addOldInputToTextareas($form, $formIdString);
|
||||
|
||||
// Select dropdowns
|
||||
$this->addOldInputToSelects($form, $formIdString);
|
||||
|
||||
// Radio buttons
|
||||
$this->addOldInputToRadios($form, $formIdString);
|
||||
|
||||
// Checkboxes
|
||||
$this->addOldInputToCheckboxes($form, $formIdString);
|
||||
}
|
||||
|
||||
private function addOldInputToTextInputs(ElementNode $node, string $formId): void
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$inputType = strtolower($child->getAttribute('type') ?? '');
|
||||
$name = $child->getAttribute('name');
|
||||
|
||||
if ($tagName === 'input' &&
|
||||
$name &&
|
||||
$inputType !== 'hidden' &&
|
||||
in_array($inputType, ['text', 'email', 'password', 'url', 'tel', 'number', 'search', ''], true)) {
|
||||
|
||||
$currentValue = $child->getAttribute('value');
|
||||
if (!$currentValue || $currentValue === '') {
|
||||
$child->setAttribute('value', "___OLD_INPUT_{$formId}_{$name}___");
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested elements (e.g., divs containing inputs)
|
||||
$this->addOldInputToTextInputs($child, $formId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function addOldInputToTextareas(ElementNode $node, string $formId): void
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$name = $child->getAttribute('name');
|
||||
|
||||
if ($tagName === 'textarea' && $name) {
|
||||
$currentContent = '';
|
||||
foreach ($child->getChildren() as $textChild) {
|
||||
if ($textChild instanceof TextNode) {
|
||||
$currentContent .= $textChild->getTextContent();
|
||||
}
|
||||
}
|
||||
|
||||
if (trim($currentContent) === '') {
|
||||
// Clear existing children and add placeholder
|
||||
$childrenToRemove = [];
|
||||
foreach ($child->getChildren() as $textChild) {
|
||||
$childrenToRemove[] = $textChild;
|
||||
}
|
||||
foreach ($childrenToRemove as $textChild) {
|
||||
$child->removeChild($textChild);
|
||||
}
|
||||
$child->appendChild(new TextNode("___OLD_INPUT_{$formId}_{$name}___"));
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$this->addOldInputToTextareas($child, $formId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function addOldInputToSelects(ElementNode $node, string $formId): void
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$name = $child->getAttribute('name');
|
||||
|
||||
if ($tagName === 'select' && $name) {
|
||||
foreach ($child->getChildren() as $option) {
|
||||
if ($option instanceof ElementNode && strtolower($option->getTagName()) === 'option') {
|
||||
$value = $option->getAttribute('value');
|
||||
if ($value) {
|
||||
$option->setAttribute(FormDataAttribute::SELECTED_IF, "___OLD_SELECT_{$formId}_{$name}_{$value}___");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$this->addOldInputToSelects($child, $formId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function addOldInputToRadios(ElementNode $node, string $formId): void
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$inputType = strtolower($child->getAttribute('type') ?? '');
|
||||
$name = $child->getAttribute('name');
|
||||
$value = $child->getAttribute('value');
|
||||
|
||||
if ($tagName === 'input' && $inputType === 'radio' && $name && $value) {
|
||||
$child->setAttribute(FormDataAttribute::CHECKED_IF, "___OLD_RADIO_{$formId}_{$name}_{$value}___");
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$this->addOldInputToRadios($child, $formId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function addOldInputToCheckboxes(ElementNode $node, string $formId): void
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$inputType = strtolower($child->getAttribute('type') ?? '');
|
||||
$name = $child->getAttribute('name');
|
||||
|
||||
if ($tagName === 'input' && $inputType === 'checkbox' && $name) {
|
||||
$value = $child->getAttribute('value') ?: '1';
|
||||
$child->setAttribute(FormDataAttribute::CHECKED_IF, "___OLD_CHECKBOX_{$formId}_{$name}_{$value}___");
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$this->addOldInputToCheckboxes($child, $formId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add error display placeholders with form ID
|
||||
*/
|
||||
private function addErrorDisplayPlaceholders(ElementNode $form, FormId $formId): void
|
||||
{
|
||||
$formIdString = (string) $formId;
|
||||
$fields = $this->findFormFields($form);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$name = $field->getAttribute('name');
|
||||
if (!$name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if error display already exists
|
||||
if ($this->hasErrorDisplay($form, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create error display placeholder with form ID
|
||||
$errorDisplay = new ElementNode('div');
|
||||
$errorDisplay->setAttribute('class', 'error-display');
|
||||
$errorDisplay->setAttribute(FormDataAttribute::FIELD, $name);
|
||||
$errorDisplay->appendChild(new TextNode("___ERROR_{$formIdString}_{$name}___"));
|
||||
|
||||
// Insert error display after the field
|
||||
$this->insertAfter($field, $errorDisplay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all form fields (input, select, textarea)
|
||||
*/
|
||||
private function findFormFields(ElementNode $node): array
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$inputType = strtolower($child->getAttribute('type') ?? '');
|
||||
|
||||
if (($tagName === 'input' && $inputType !== 'hidden') ||
|
||||
$tagName === 'select' ||
|
||||
$tagName === 'textarea') {
|
||||
$name = $child->getAttribute('name');
|
||||
if ($name) {
|
||||
$fields[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$fields = array_merge($fields, $this->findFormFields($child));
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a field by name
|
||||
*/
|
||||
private function findFieldByName(ElementNode $node, string $fieldName): ?ElementNode
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
if ($tagName === 'input' && $child->getAttribute('name') === $fieldName) {
|
||||
return $child;
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$found = $this->findFieldByName($child, $fieldName);
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error display already exists for field
|
||||
*/
|
||||
private function hasErrorDisplay(ElementNode $node, string $fieldName): bool
|
||||
{
|
||||
return $this->findErrorDisplay($node, $fieldName) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find error display for field
|
||||
*/
|
||||
private function findErrorDisplay(ElementNode $node, string $fieldName): ?ElementNode
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$classes = $child->getAttribute('class') ?? '';
|
||||
$dataField = $child->getAttribute(FormDataAttribute::FIELD);
|
||||
|
||||
if ($tagName === 'div' &&
|
||||
str_contains($classes, 'error-display') &&
|
||||
$dataField === $fieldName) {
|
||||
return $child;
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$found = $this->findErrorDisplay($child, $fieldName);
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert node after another node
|
||||
*/
|
||||
private function insertAfter(ElementNode $sibling, Node $newNode): void
|
||||
{
|
||||
$parent = $sibling->getParent();
|
||||
if ($parent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$children = $parent->getChildren();
|
||||
$siblingIndex = array_search($sibling, $children, true);
|
||||
|
||||
if ($siblingIndex === false) {
|
||||
// Sibling not found, append to parent
|
||||
$parent->appendChild($newNode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all children
|
||||
foreach ($children as $child) {
|
||||
$parent->removeChild($child);
|
||||
}
|
||||
|
||||
// Re-insert children, inserting new node after sibling
|
||||
foreach ($children as $index => $child) {
|
||||
$parent->appendChild($child);
|
||||
if ($index === $siblingIndex) {
|
||||
$parent->appendChild($newNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate form ID from ElementNode (similar to FormIdGenerator::generateFromRenderContext)
|
||||
*/
|
||||
private function generateFormIdFromElement(ElementNode $form, RenderContext $context): FormId
|
||||
{
|
||||
// 1. Use existing ID if set
|
||||
$existingId = $form->getAttribute('id');
|
||||
if ($existingId) {
|
||||
return FormId::fromString($existingId);
|
||||
}
|
||||
|
||||
// 2. Generate from form action and method
|
||||
$action = $form->getAttribute('action') ?? '/';
|
||||
$method = $form->getAttribute('method') ?? 'post';
|
||||
|
||||
$formId = $this->formIdGenerator->generateFormId($action, $method);
|
||||
|
||||
return FormId::fromString($formId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form is inside a LiveComponent wrapper
|
||||
*/
|
||||
private function isInsideLiveComponent(ElementNode $form): bool
|
||||
{
|
||||
$parent = $form->getParent();
|
||||
|
||||
while ($parent !== null) {
|
||||
if ($parent instanceof ElementNode) {
|
||||
// Check if parent has data-live-component attribute
|
||||
if ($parent->hasAttribute(LiveComponentCoreAttribute::LIVE_COMPONENT)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$parent = $parent->getParent();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a child to the beginning of parent's children
|
||||
*/
|
||||
private function prependChild(ElementNode $parent, Node $child): void
|
||||
{
|
||||
$children = $parent->getChildren();
|
||||
|
||||
// Remove all children
|
||||
foreach ($children as $existingChild) {
|
||||
$parent->removeChild($existingChild);
|
||||
}
|
||||
|
||||
// Add new child first
|
||||
$parent->appendChild($child);
|
||||
|
||||
// Add back existing children
|
||||
foreach ($children as $existingChild) {
|
||||
$parent->appendChild($existingChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,32 +8,36 @@ 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\RenderContext;
|
||||
use App\Framework\View\Dom\Parser\HtmlParser;
|
||||
use App\Framework\Http\Session\FormIdGenerator;
|
||||
|
||||
/**
|
||||
* HoneypotTransformer - Adds honeypot spam protection to forms
|
||||
*
|
||||
* Adds hidden fields to forms for bot detection:
|
||||
* - Randomized honeypot field (bots will fill it)
|
||||
* - Time validation field (bots submit too quickly)
|
||||
* Adds placeholders for honeypot fields that will be replaced by FormDataResponseProcessor:
|
||||
* - Honeypot field placeholder (___HONEYPOT_FORMID___)
|
||||
* - Honeypot name placeholder (___HONEYPOT_NAME_FORMID___)
|
||||
* - Time validation placeholder (___HONEYPOT_TIME_FORMID___)
|
||||
*
|
||||
* This transformer runs after FormTransformer, so form IDs are already available.
|
||||
*
|
||||
* Framework Pattern: readonly class, AST-based transformation
|
||||
*/
|
||||
final readonly class HoneypotTransformer implements AstTransformer
|
||||
{
|
||||
private const array HONEYPOT_NAMES = [
|
||||
'email_confirm',
|
||||
'website_url',
|
||||
'phone_number',
|
||||
'user_name',
|
||||
'company_name',
|
||||
];
|
||||
public function __construct(
|
||||
private FormIdGenerator $formIdGenerator,
|
||||
private HtmlParser $parser
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
{
|
||||
// Find all form elements and add honeypot fields
|
||||
$this->processForms($document);
|
||||
// Find all form elements and add honeypot placeholders
|
||||
$this->processForms($document, $context);
|
||||
|
||||
return $document;
|
||||
}
|
||||
@@ -41,30 +45,147 @@ final readonly class HoneypotTransformer implements AstTransformer
|
||||
/**
|
||||
* Recursively find and process form elements
|
||||
*/
|
||||
private function processForms(Node $node): void
|
||||
private function processForms(Node $node, RenderContext $context): void
|
||||
{
|
||||
// Process RawHtmlNode - parse HTML and process forms inside
|
||||
if ($node instanceof RawHtmlNode) {
|
||||
$this->processRawHtmlNode($node, $context);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($node instanceof ElementNode) {
|
||||
// Check if this is a form element
|
||||
if (strtolower($node->getTagName()) === 'form') {
|
||||
$this->addHoneypot($node);
|
||||
$this->addTimeValidation($node);
|
||||
// Extract form ID from _form_id field (added by FormTransformer)
|
||||
$formId = $this->extractFormIdFromForm($node);
|
||||
|
||||
if ($formId) {
|
||||
$this->addHoneypotPlaceholders($node, $formId);
|
||||
} else {
|
||||
// Fallback: generate form ID if not found
|
||||
$formId = $this->generateFormIdFromElement($node, $context);
|
||||
$this->addHoneypotPlaceholders($node, $formId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into children for both ElementNode and DocumentNode
|
||||
if ($node instanceof ElementNode || $node instanceof DocumentNode) {
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$this->processForms($child);
|
||||
$this->processForms($child, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add honeypot field to form
|
||||
* Process RawHtmlNode - parse HTML, process forms, and update the node
|
||||
*/
|
||||
private function addHoneypot(ElementNode $form): void
|
||||
private function processRawHtmlNode(RawHtmlNode $node, RenderContext $context): void
|
||||
{
|
||||
$honeypotName = self::HONEYPOT_NAMES[array_rand(self::HONEYPOT_NAMES)];
|
||||
$html = $node->getHtml();
|
||||
|
||||
// Check if HTML contains forms
|
||||
if (!str_contains($html, '<form')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse HTML to AST
|
||||
$parsed = $this->parser->parse($html);
|
||||
|
||||
// Process forms in parsed AST
|
||||
$this->processForms($parsed, $context);
|
||||
|
||||
// Render back to HTML
|
||||
$renderer = new \App\Framework\View\Dom\Renderer\HtmlRenderer();
|
||||
$processedHtml = $renderer->render($parsed);
|
||||
|
||||
// Update RawHtmlNode with processed HTML
|
||||
$node->setHtml($processedHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form ID from form element (from _form_id field added by FormTransformer)
|
||||
*/
|
||||
private function extractFormIdFromForm(ElementNode $form): ?string
|
||||
{
|
||||
foreach ($form->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$name = $child->getAttribute('name');
|
||||
|
||||
if ($tagName === 'input' && $name === '_form_id') {
|
||||
$formId = $child->getAttribute('value');
|
||||
if ($formId && str_starts_with($formId, 'form_')) {
|
||||
return $formId;
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested elements
|
||||
$found = $this->findFormIdInChildren($child);
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find _form_id field in children
|
||||
*/
|
||||
private function findFormIdInChildren(ElementNode $node): ?string
|
||||
{
|
||||
foreach ($node->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$tagName = strtolower($child->getTagName());
|
||||
$name = $child->getAttribute('name');
|
||||
|
||||
if ($tagName === 'input' && $name === '_form_id') {
|
||||
$formId = $child->getAttribute('value');
|
||||
if ($formId && str_starts_with($formId, 'form_')) {
|
||||
return $formId;
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse deeper
|
||||
$found = $this->findFormIdInChildren($child);
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate form ID from form element (fallback if not found)
|
||||
*/
|
||||
private function generateFormIdFromElement(ElementNode $form, RenderContext $context): string
|
||||
{
|
||||
// Use existing ID if set
|
||||
$existingId = $form->getAttribute('id');
|
||||
if ($existingId && str_starts_with($existingId, 'form_')) {
|
||||
return $existingId;
|
||||
}
|
||||
|
||||
// Generate from form action and method
|
||||
$action = $form->getAttribute('action') ?? '/';
|
||||
$method = $form->getAttribute('method') ?? 'post';
|
||||
|
||||
return $this->formIdGenerator->generateFormId($action, $method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add honeypot placeholders to form
|
||||
*/
|
||||
private function addHoneypotPlaceholders(ElementNode $form, string $formId): void
|
||||
{
|
||||
// Check if honeypot fields already exist
|
||||
if ($this->hasHoneypotFields($form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create hidden container for honeypot
|
||||
$container = new ElementNode('div');
|
||||
@@ -73,14 +194,14 @@ final readonly class HoneypotTransformer implements AstTransformer
|
||||
|
||||
// Create label (looks realistic to bots)
|
||||
$label = new ElementNode('label');
|
||||
$label->setAttribute('for', $honeypotName);
|
||||
$label->setAttribute('for', "___HONEYPOT_NAME_{$formId}___");
|
||||
$label->appendChild(new TextNode('Website (optional)'));
|
||||
|
||||
// Create honeypot input
|
||||
// Create honeypot input with placeholder for name
|
||||
$honeypot = new ElementNode('input');
|
||||
$honeypot->setAttribute('type', 'text');
|
||||
$honeypot->setAttribute('name', $honeypotName);
|
||||
$honeypot->setAttribute('id', $honeypotName);
|
||||
$honeypot->setAttribute('name', "___HONEYPOT_NAME_{$formId}___");
|
||||
$honeypot->setAttribute('id', "___HONEYPOT_NAME_{$formId}___");
|
||||
$honeypot->setAttribute('autocomplete', 'off');
|
||||
$honeypot->setAttribute('tabindex', '-1');
|
||||
|
||||
@@ -88,29 +209,39 @@ final readonly class HoneypotTransformer implements AstTransformer
|
||||
$container->appendChild($label);
|
||||
$container->appendChild($honeypot);
|
||||
|
||||
// Create hidden field with honeypot name for server-side validation
|
||||
// Create hidden field with honeypot name placeholder
|
||||
$nameField = new ElementNode('input');
|
||||
$nameField->setAttribute('type', 'hidden');
|
||||
$nameField->setAttribute('name', '_honeypot_name');
|
||||
$nameField->setAttribute('value', $honeypotName);
|
||||
$nameField->setAttribute('value', "___HONEYPOT_NAME_{$formId}___");
|
||||
|
||||
// Create time validation field with placeholder
|
||||
$timeField = new ElementNode('input');
|
||||
$timeField->setAttribute('type', 'hidden');
|
||||
$timeField->setAttribute('name', '_form_start_time');
|
||||
$timeField->setAttribute('value', "___HONEYPOT_TIME_{$formId}___");
|
||||
|
||||
// Insert at beginning of form (before other fields)
|
||||
$this->prependChild($form, $container);
|
||||
$this->prependChild($form, $nameField);
|
||||
$this->prependChild($form, $timeField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add time validation field to form
|
||||
* Check if form already has honeypot fields
|
||||
*/
|
||||
private function addTimeValidation(ElementNode $form): void
|
||||
private function hasHoneypotFields(ElementNode $form): bool
|
||||
{
|
||||
$timeField = new ElementNode('input');
|
||||
$timeField->setAttribute('type', 'hidden');
|
||||
$timeField->setAttribute('name', '_form_start_time');
|
||||
$timeField->setAttribute('value', (string)time());
|
||||
|
||||
// Insert at beginning of form
|
||||
$this->prependChild($form, $timeField);
|
||||
foreach ($form->getChildren() as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$name = $child->getAttribute('name');
|
||||
if ($name === '_honeypot_name' || $name === '_form_start_time') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,7 +59,112 @@ final readonly class IfTransformer implements AstTransformer
|
||||
// Process current node if it has the attribute
|
||||
if ($node instanceof ElementNode && $node->hasAttribute($attributeName)) {
|
||||
$condition = $node->getAttribute($attributeName);
|
||||
$result = $this->evaluateCondition($context->data, $condition);
|
||||
|
||||
// Decode HTML entities (' -> ', " -> ")
|
||||
$condition = html_entity_decode($condition, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Get all variables from context
|
||||
$allVariables = $context->getAllVariables();
|
||||
|
||||
// Process condition: support both {{ }} placeholder syntax and direct array access
|
||||
// 1. If condition contains {{ }}, process placeholders first
|
||||
// 2. If condition contains array access without {{ }}, resolve it directly
|
||||
// 3. Then evaluate the final condition
|
||||
|
||||
// Step 1: Process {{ }} placeholders if present
|
||||
$hasPlaceholders = str_contains($condition, '{{');
|
||||
if ($hasPlaceholders) {
|
||||
$condition = preg_replace_callback('/{{\\s*\\$?([^}]+?)\\s*}}/', function($matches) use ($allVariables) {
|
||||
$expr = trim($matches[1]);
|
||||
|
||||
// Decode HTML entities in expression (' -> ', " -> ")
|
||||
$expr = html_entity_decode($expr, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Check if expression contains comparison operators (>, <, ==, etc.)
|
||||
if (preg_match('/^(.+?)\s*(>|>=|<|<=|==|!=|===|!==)\s*(.+)$/', $expr, $comparisonMatches)) {
|
||||
$leftExpr = trim($comparisonMatches[1]);
|
||||
$operator = $comparisonMatches[2];
|
||||
$rightExpr = trim($comparisonMatches[3]);
|
||||
|
||||
// Resolve left side
|
||||
$leftValue = $this->resolvePlaceholderValue($leftExpr, $allVariables);
|
||||
// Resolve right side
|
||||
$rightValue = $this->resolvePlaceholderValue($rightExpr, $allVariables);
|
||||
|
||||
// Format for expression evaluator
|
||||
$leftFormatted = $this->formatValueForExpression($leftValue);
|
||||
$rightFormatted = $this->formatValueForExpression($rightValue);
|
||||
|
||||
return $leftFormatted . ' ' . $operator . ' ' . $rightFormatted;
|
||||
}
|
||||
|
||||
// Simple placeholder - resolve to value
|
||||
$value = $this->resolvePlaceholderValue($expr, $allVariables);
|
||||
|
||||
// If value is boolean, return as string literal
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// If value is null, return 'false' (null is falsy)
|
||||
if ($value === null) {
|
||||
return 'false';
|
||||
}
|
||||
|
||||
// For other values, format them for expression evaluator
|
||||
return $this->formatValueForExpression($value);
|
||||
}, $condition);
|
||||
}
|
||||
|
||||
// Step 2: Process direct array access without {{ }} (e.g., $pagination['has_next'])
|
||||
// This allows conditions like: if="$pagination['has_next']" to work
|
||||
// Only process if no placeholders were found (to avoid double processing)
|
||||
if (!$hasPlaceholders && preg_match('/\$[a-zA-Z_][a-zA-Z0-9_]*\[/', $condition)) {
|
||||
// Check if condition contains array access patterns
|
||||
$condition = preg_replace_callback('/\$([a-zA-Z_][a-zA-Z0-9_]*)\[(["\']?)([^"\'\]]+)\\2\]/', function($matches) use ($allVariables) {
|
||||
$varName = $matches[1];
|
||||
$key = $matches[3];
|
||||
|
||||
if (array_key_exists($varName, $allVariables)) {
|
||||
$value = $allVariables[$varName];
|
||||
|
||||
if (is_numeric($key)) {
|
||||
$key = (int) $key;
|
||||
}
|
||||
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$result = $value[$key];
|
||||
// Format boolean values as string literals
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return 'false';
|
||||
}
|
||||
return $this->formatValueForExpression($result);
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (isset($value->$key)) {
|
||||
$result = $value->$key;
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return 'false';
|
||||
}
|
||||
return $this->formatValueForExpression($result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found, return 'false'
|
||||
return 'false';
|
||||
}, $condition);
|
||||
}
|
||||
|
||||
// Step 3: Evaluate the final condition
|
||||
$result = $this->evaluateCondition($allVariables, $condition);
|
||||
|
||||
if (! $result) {
|
||||
// Mark node for removal by clearing children and setting marker
|
||||
@@ -95,4 +200,80 @@ final readonly class IfTransformer implements AstTransformer
|
||||
{
|
||||
return $this->evaluator->evaluateCondition($condition, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve placeholder value from expression (e.g., pagination['total_pages'] or $pagination['has_next'])
|
||||
*/
|
||||
private function resolvePlaceholderValue(string $expr, array $allVariables): mixed
|
||||
{
|
||||
$expr = trim($expr);
|
||||
|
||||
// Remove $ prefix if present for array access matching
|
||||
$exprWithoutPrefix = ltrim($expr, '$');
|
||||
|
||||
// Try to resolve array bracket notation: pagination['current_page'] or $pagination['has_next']
|
||||
// Pattern: optional $, then var name, then ['key'] or ["key"]
|
||||
if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[(["\']?)([^"\'\]]+)\\2\\]$/', $expr, $arrayMatches)) {
|
||||
$varName = $arrayMatches[1];
|
||||
$key = $arrayMatches[3];
|
||||
|
||||
if (array_key_exists($varName, $allVariables)) {
|
||||
$value = $allVariables[$varName];
|
||||
|
||||
if (is_numeric($key)) {
|
||||
$key = (int) $key;
|
||||
}
|
||||
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
return $value[$key];
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (isset($value->$key)) {
|
||||
return $value->$key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try simple variable access (with or without $ prefix)
|
||||
if (array_key_exists($expr, $allVariables)) {
|
||||
return $allVariables[$expr];
|
||||
}
|
||||
|
||||
// Try with $ prefix removed
|
||||
if (array_key_exists($exprWithoutPrefix, $allVariables)) {
|
||||
return $allVariables[$exprWithoutPrefix];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value for expression evaluator
|
||||
*/
|
||||
private function formatValueForExpression(mixed $value): string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
if ($value === null) {
|
||||
return 'null';
|
||||
}
|
||||
if (is_array($value)) {
|
||||
// Arrays cannot be used directly in expressions
|
||||
// Return 'null' as arrays are not directly comparable in conditions
|
||||
return 'null';
|
||||
}
|
||||
if (is_object($value)) {
|
||||
// Objects cannot be used directly in expressions
|
||||
// Return 'null' as objects are not directly comparable in conditions
|
||||
return 'null';
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
// For strings, wrap in quotes
|
||||
return "'" . addslashes((string) $value) . "'";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Framework\View\Dom\DocumentNode;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\Parser\HtmlParser;
|
||||
use App\Framework\View\Dom\Renderer\HtmlRenderer;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
use App\Framework\View\Loading\TemplateLoader;
|
||||
use App\Framework\View\Processors\PlaceholderReplacer;
|
||||
@@ -93,11 +94,131 @@ final readonly class LayoutTagTransformer implements AstTransformer
|
||||
return $document;
|
||||
}
|
||||
|
||||
// Get content to insert (either innerHTML of layout tag or content after it)
|
||||
$contentNodes = $this->collectContent($layoutTag, $document);
|
||||
|
||||
// IMPORTANT: Replace {{{bodyContent}}} placeholder with actual body content BEFORE processing
|
||||
// This placeholder should contain the rendered body content from the controller
|
||||
// We need to replace it before inserting into layout, otherwise placeholders won't work
|
||||
$bodyContentRaw = $context->data['bodyContent'] ?? null;
|
||||
|
||||
// Debug: Log bodyContent availability
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("LayoutTagTransformer::applyLayout: bodyContent in context->data: " . (isset($context->data['bodyContent']) ? 'YES' : 'NO'));
|
||||
if (isset($context->data['bodyContent'])) {
|
||||
$type = gettype($context->data['bodyContent']);
|
||||
$class = is_object($context->data['bodyContent']) ? get_class($context->data['bodyContent']) : 'N/A';
|
||||
error_log("LayoutTagTransformer::applyLayout: bodyContent type: {$type}, class: {$class}");
|
||||
} else {
|
||||
error_log("LayoutTagTransformer::applyLayout: Available keys in context->data: " . implode(', ', array_keys($context->data)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($bodyContentRaw !== null) {
|
||||
// Convert to string (handles RawHtml objects and strings)
|
||||
// RawHtml implements __toString(), so we can cast it directly
|
||||
$bodyContent = (string) $bodyContentRaw;
|
||||
|
||||
// Debug: Log bodyContent content
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("LayoutTagTransformer::applyLayout: bodyContent length: " . strlen($bodyContent));
|
||||
error_log("LayoutTagTransformer::applyLayout: bodyContent preview: " . substr($bodyContent, 0, 200));
|
||||
}
|
||||
|
||||
// Parse bodyContent as HTML and insert as AST nodes instead of replacing text
|
||||
// This ensures that placeholders in bodyContent are preserved
|
||||
if (!empty($bodyContent)) {
|
||||
$bodyDocument = $this->parser->parse($bodyContent);
|
||||
// Replace TextNode containing {{{bodyContent}}} with parsed body content nodes
|
||||
$newContentNodes = [];
|
||||
foreach ($contentNodes as $node) {
|
||||
if ($node instanceof \App\Framework\View\Dom\TextNode) {
|
||||
$text = $node->getTextContent();
|
||||
if (str_contains($text, '{{{bodyContent}}}')) {
|
||||
// Replace placeholder with parsed body content nodes
|
||||
foreach ($bodyDocument->getChildren() as $bodyNode) {
|
||||
$newContentNodes[] = $bodyNode;
|
||||
}
|
||||
// Keep any text before/after the placeholder
|
||||
$parts = explode('{{{bodyContent}}}', $text);
|
||||
if (!empty($parts[0])) {
|
||||
$newContentNodes[] = new \App\Framework\View\Dom\TextNode($parts[0]);
|
||||
}
|
||||
if (isset($parts[1]) && !empty($parts[1])) {
|
||||
$newContentNodes[] = new \App\Framework\View\Dom\TextNode($parts[1]);
|
||||
}
|
||||
} else {
|
||||
$newContentNodes[] = $node;
|
||||
}
|
||||
} else {
|
||||
$newContentNodes[] = $node;
|
||||
}
|
||||
}
|
||||
$contentNodes = $newContentNodes;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log content nodes
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("LayoutTagTransformer::applyLayout: Collected " . count($contentNodes) . " content nodes");
|
||||
foreach ($contentNodes as $idx => $node) {
|
||||
$nodeType = get_class($node);
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx} type: {$nodeType}");
|
||||
|
||||
if ($node instanceof \App\Framework\View\Dom\ElementNode) {
|
||||
$tagName = $node->getTagName();
|
||||
$attrs = $node->getAttributes();
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx}: <{$tagName}> with " . count($attrs) . " attributes");
|
||||
foreach ($attrs as $attr) {
|
||||
if (str_contains($attr->name, 'foreach') || str_contains($attr->value, '{{')) {
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx} attribute: {$attr}");
|
||||
}
|
||||
}
|
||||
// Check children for placeholders
|
||||
foreach ($node->getChildren() as $childIdx => $child) {
|
||||
if ($child instanceof \App\Framework\View\Dom\ElementNode) {
|
||||
$childAttrs = $child->getAttributes();
|
||||
foreach ($childAttrs as $attr) {
|
||||
if (str_contains($attr->name, 'href') || str_contains($attr->value, '{{')) {
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx} child {$childIdx} attribute: {$attr}");
|
||||
}
|
||||
}
|
||||
} elseif ($child instanceof \App\Framework\View\Dom\TextNode) {
|
||||
$text = $child->getTextContent();
|
||||
if (str_contains($text, '{{')) {
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx} child {$childIdx} (TextNode) contains placeholder: " . substr($text, 0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($node instanceof \App\Framework\View\Dom\TextNode) {
|
||||
$text = $node->getTextContent();
|
||||
$textLength = strlen($text);
|
||||
$textHex = bin2hex(substr($text, 0, 50));
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx} (TextNode) content length: {$textLength}, hex: {$textHex}");
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx} (TextNode) content (first 200 chars): " . substr($text, 0, 200));
|
||||
if (str_contains($text, '{{')) {
|
||||
error_log("LayoutTagTransformer::applyLayout: Content node {$idx} (TextNode) contains placeholder!");
|
||||
// Find all placeholders
|
||||
preg_match_all('/\{\{[^}]+\}\}/', $text, $matches);
|
||||
if (!empty($matches[0])) {
|
||||
error_log("LayoutTagTransformer::applyLayout: Found placeholders: " . implode(', ', $matches[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load layout template
|
||||
$layoutPath = $this->loader->getTemplatePath($layoutFile);
|
||||
// Verwende controllerClass aus dem RenderContext, damit ControllerResolver das Layout finden kann
|
||||
// Dies ermöglicht es, Layouts relativ zum Controller zu finden (z.B. Admin-Layouts)
|
||||
$layoutPath = $this->loader->getTemplatePath($layoutFile, $context->controllerClass);
|
||||
$layoutContent = file_get_contents($layoutPath);
|
||||
|
||||
// Process basic placeholders in layout (not components or loops yet)
|
||||
// Replace {{ $content }} with empty string so PlaceholderReplacer doesn't complain
|
||||
// We'll insert the content directly into the <main> element after parsing
|
||||
$layoutContent = preg_replace('/\{\{\s*\$content\s*\}\}/', '', $layoutContent);
|
||||
|
||||
// Process placeholders in layout (but NOT in content - PlaceholderTransformer will handle that)
|
||||
$layoutContext = new RenderContext(
|
||||
template: $layoutFile,
|
||||
metaData: $context->metaData,
|
||||
@@ -109,23 +230,35 @@ final readonly class LayoutTagTransformer implements AstTransformer
|
||||
|
||||
// Parse layout to AST
|
||||
$layoutDocument = $this->parser->parse($processedLayoutHtml);
|
||||
|
||||
// Find slot (<main> element) in layout
|
||||
$slot = $this->findMainSlot($layoutDocument);
|
||||
|
||||
if (!$slot) {
|
||||
// No slot found, return original document
|
||||
return $document;
|
||||
}
|
||||
|
||||
// Get content to insert (either innerHTML of layout tag or content after it)
|
||||
$contentToInsert = $this->collectContent($layoutTag, $document);
|
||||
|
||||
// Clear slot and insert content
|
||||
$this->clearChildren($slot);
|
||||
|
||||
foreach ($contentToInsert as $node) {
|
||||
$slot->appendChild($node);
|
||||
|
||||
// IMPORTANT: Don't render and re-parse content - insert content nodes directly as AST
|
||||
// This prevents loss of attributes and placeholders during render/re-parse cycle
|
||||
// The content nodes are already AST nodes, so we can insert them directly
|
||||
|
||||
// Find <main> element and insert content directly
|
||||
$mainElement = $this->findMainSlot($layoutDocument);
|
||||
if ($mainElement) {
|
||||
// Clear any existing content (like whitespace from the removed placeholder)
|
||||
$this->clearChildren($mainElement);
|
||||
|
||||
// Insert content nodes directly into <main> (they're already AST nodes)
|
||||
// Placeholders will be processed by PlaceholderTransformer after insertion
|
||||
foreach ($contentNodes as $contentNode) {
|
||||
$clonedContentNode = $contentNode->clone();
|
||||
|
||||
// Debug: Log cloned node attributes
|
||||
if (getenv('APP_DEBUG') === 'true' && $clonedContentNode instanceof \App\Framework\View\Dom\ElementNode) {
|
||||
$attrs = $clonedContentNode->getAttributes();
|
||||
foreach ($attrs as $attr) {
|
||||
if (str_contains($attr->name, 'foreach') || str_contains($attr->value, '{{')) {
|
||||
error_log("LayoutTagTransformer::applyLayout: Cloned node attribute before insertion: {$attr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$clonedContentNode->setParent($mainElement);
|
||||
$mainElement->appendChild($clonedContentNode);
|
||||
}
|
||||
}
|
||||
|
||||
return $layoutDocument;
|
||||
@@ -207,10 +340,16 @@ final readonly class LayoutTagTransformer implements AstTransformer
|
||||
|
||||
if ($found && $node !== $target) {
|
||||
// Collect this node if we're past the target (sibling-level only)
|
||||
if (!($node instanceof TextNode && trim($node->getTextContent()) === '')) {
|
||||
// Skip empty text nodes (whitespace)
|
||||
if ($node instanceof TextNode) {
|
||||
if (trim($node->getTextContent()) !== '') {
|
||||
$content[] = $node;
|
||||
}
|
||||
} else {
|
||||
// Collect non-text nodes (elements, etc.)
|
||||
$content[] = $node;
|
||||
}
|
||||
// Don't recurse into collected nodes - we collect them as-is
|
||||
// Don't recurse into collected nodes - they already contain their children
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,4 +428,5 @@ final readonly class LayoutTagTransformer implements AstTransformer
|
||||
$node->removeChild($child);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
883
src/Framework/View/Dom/Transformer/PlaceholderTransformer.php
Normal file
883
src/Framework/View/Dom/Transformer/PlaceholderTransformer.php
Normal file
@@ -0,0 +1,883 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom\Transformer;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\LiveComponents\ComponentRegistry;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\Template\Expression\ExpressionEvaluator;
|
||||
use App\Framework\Template\Processing\AstTransformer;
|
||||
use App\Framework\View\Dom\DocumentNode;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\RawHtmlNode;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
use App\Framework\View\Exceptions\PlaceholderException;
|
||||
use App\Framework\View\Formatting\ValueFormatter;
|
||||
use App\Framework\View\Functions\AssetSlotFunction;
|
||||
use App\Framework\View\Functions\LazyComponentFunction;
|
||||
use App\Framework\View\Functions\UrlFunction;
|
||||
use App\Framework\View\Functions\ViteTagsFunction;
|
||||
use App\Framework\View\RawHtml;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\View\Table\Table;
|
||||
use App\Framework\View\TemplateFunctions;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use ReflectionObject;
|
||||
|
||||
/**
|
||||
* PlaceholderTransformer - AST-based placeholder replacement
|
||||
*
|
||||
* Processes placeholders {{ expression }} in templates using ExpressionEvaluator.
|
||||
* Supports nested scopes from ForTransformer for loop variables.
|
||||
*
|
||||
* Supports:
|
||||
* - Simple variables: {{ $name }}, {{ name }}
|
||||
* - Array access: {{ $user['email'] }}, {{ $items[0] }}
|
||||
* - Object properties: {{ $user->name }}
|
||||
* - Dot notation: {{ user.name }}
|
||||
* - Expressions: {{ $count > 0 }}
|
||||
* - Template functions: {{ date('Y-m-d') }}
|
||||
* - Raw HTML: {{{ $content }}}
|
||||
*
|
||||
* Framework Pattern: readonly class, AST-based transformation, composition with ExpressionEvaluator
|
||||
*/
|
||||
final readonly class PlaceholderTransformer implements AstTransformer
|
||||
{
|
||||
private ExpressionEvaluator $evaluator;
|
||||
private ValueFormatter $formatter;
|
||||
|
||||
/**
|
||||
* @var string[] Erlaubte Template-Funktionen
|
||||
*/
|
||||
private array $allowedTemplateFunctions;
|
||||
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
$this->evaluator = new ExpressionEvaluator();
|
||||
$this->formatter = new ValueFormatter($container);
|
||||
$this->allowedTemplateFunctions = [
|
||||
'date', 'format_date', 'format_currency', 'format_filesize',
|
||||
'strtoupper', 'strtolower', 'ucfirst', 'trim', 'count',
|
||||
'number_format', 'json_encode',
|
||||
];
|
||||
}
|
||||
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
{
|
||||
// Skip placeholder processing if placeholders were already processed by ForTransformer
|
||||
// ForTransformer processes placeholders in loop contexts with scopes
|
||||
// We only process placeholders that weren't processed yet (outside of loops)
|
||||
$this->processNode($document, $context);
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a node and its children recursively
|
||||
*/
|
||||
private function processNode(Node $node, RenderContext $context): void
|
||||
{
|
||||
if ($node instanceof TextNode) {
|
||||
$this->processTextNode($node, $context);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($node instanceof ElementNode) {
|
||||
$this->processElementNode($node, $context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process children for DocumentNode and other container nodes
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$this->processNode($child, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in text content
|
||||
*/
|
||||
private function processTextNode(TextNode $node, RenderContext $context): void
|
||||
{
|
||||
$text = $node->getTextContent();
|
||||
// Skip if text doesn't contain placeholders (already processed by ForTransformer)
|
||||
if (!str_contains($text, '{{')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->processPlaceholders($text, $context);
|
||||
$processed = $result['html'];
|
||||
$isRawHtml = $result['isRawHtml'];
|
||||
|
||||
// Wenn Raw HTML erkannt wurde, erstelle RawHtmlNode statt TextNode
|
||||
if ($isRawHtml) {
|
||||
// Ersetze TextNode durch RawHtmlNode
|
||||
$parent = $node->getParent();
|
||||
if ($parent) {
|
||||
$rawHtmlNode = new RawHtmlNode($processed);
|
||||
|
||||
// Verwende Reflection, um das Kind im Parent zu ersetzen (ähnlich wie XComponentTransformer)
|
||||
$parentReflection = new ReflectionObject($parent);
|
||||
$childrenProperty = $parentReflection->getProperty('children');
|
||||
$children = $childrenProperty->getValue($parent);
|
||||
|
||||
$index = array_search($node, $children, true);
|
||||
if ($index !== false) {
|
||||
$children[$index] = $rawHtmlNode;
|
||||
$childrenProperty->setValue($parent, $children);
|
||||
$rawHtmlNode->setParent($parent);
|
||||
$node->setParent(null);
|
||||
|
||||
// Debug: Log replacement
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("PlaceholderTransformer: Replaced TextNode with RawHtmlNode (length=" . strlen($processed) . ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$node->setText($processed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in element attributes and children
|
||||
*/
|
||||
private function processElementNode(ElementNode $node, RenderContext $context): void
|
||||
{
|
||||
// Process attributes
|
||||
foreach (array_keys($node->getAttributes()) as $attrName) {
|
||||
$attrValue = $node->getAttribute($attrName);
|
||||
if ($attrValue !== null) {
|
||||
// Skip processing for 'if', 'condition', and 'items'/'in' attributes
|
||||
// - IfTransformer will handle 'if' and 'condition'
|
||||
// - ForTransformer will handle 'items' and 'in' (needs to resolve to actual arrays)
|
||||
if ($attrName === 'if' || $attrName === 'condition' || $attrName === 'items' || $attrName === 'in') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode HTML entities (' -> ', " -> ")
|
||||
$decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Check if this is a single placeholder that might return an array/object
|
||||
// (e.g., pagination="{{$pagination}}")
|
||||
if (preg_match('/^{{\s*([^}]+?)\s*}}$/', $decodedValue, $matches)) {
|
||||
$expression = trim($matches[1]);
|
||||
$allVariables = $context->getAllVariables();
|
||||
try {
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
// If the value is an array or object, store it directly (not as string)
|
||||
// XComponentTransformer will handle it correctly
|
||||
if (is_array($value) || is_object($value)) {
|
||||
// Store as special marker that XComponentTransformer can recognize
|
||||
// We'll use a JSON-encoded string that XComponentTransformer can decode
|
||||
$node->setAttribute($attrName, json_encode($value, JSON_THROW_ON_ERROR));
|
||||
continue;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// If evaluation fails, fall through to normal processing
|
||||
}
|
||||
}
|
||||
|
||||
// For href attributes, use raw processing to avoid escaping URLs
|
||||
// URLs should not be escaped (they will be escaped by setAttribute if needed)
|
||||
if ($attrName === 'href' && str_contains($decodedValue, '{{')) {
|
||||
$processedValue = $this->processPlaceholdersRaw($decodedValue, $context);
|
||||
} else {
|
||||
// Process placeholders with escaping
|
||||
$result = $this->processPlaceholders($decodedValue, $context);
|
||||
$processedValue = $result['html'];
|
||||
}
|
||||
|
||||
// Set the processed value (will be re-encoded if needed during rendering)
|
||||
$node->setAttribute($attrName, $processedValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$this->processNode($child, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all placeholders in a string
|
||||
*
|
||||
* @return array{html: string, isRawHtml: bool} HTML-String und Flag, ob Raw HTML enthalten ist
|
||||
*/
|
||||
private function processPlaceholders(string $html, RenderContext $context): array
|
||||
{
|
||||
// Get all variables from scopes and data
|
||||
$allVariables = $context->getAllVariables();
|
||||
|
||||
// Prüfe, ob der Original-Text {{{ }}} Platzhalter enthält
|
||||
$containsRawPlaceholders = preg_match('/{{{/', $html) === 1;
|
||||
|
||||
// Step 1: Process template functions first (e.g., {{ date('Y-m-d') }})
|
||||
$html = $this->processTemplateFunctions($html, $context, $allVariables);
|
||||
|
||||
// Step 2: Process raw HTML placeholders {{{ expression }}
|
||||
$html = $this->processRawPlaceholders($html, $allVariables);
|
||||
|
||||
// Step 3: Process standard placeholders {{ expression }}
|
||||
$html = $this->processStandardPlaceholders($html, $allVariables);
|
||||
|
||||
// Prüfe, ob das Ergebnis HTML-Tags enthält (wenn Raw Platzhalter vorhanden waren)
|
||||
$isRawHtml = $containsRawPlaceholders && preg_match('/<[a-z][\s\S]*>/i', $html) === 1;
|
||||
|
||||
return ['html' => $html, 'isRawHtml' => $isRawHtml];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process template functions: {{ functionName(params) }}
|
||||
*/
|
||||
private function processTemplateFunctions(string $html, RenderContext $context, array $allVariables): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/{{\\s*([a-zA-Z_][a-zA-Z0-9_]*)\\(([^)]*)\\)\\s*}}/',
|
||||
function ($matches) use ($context, $allVariables) {
|
||||
$functionName = $matches[1];
|
||||
$params = trim($matches[2]);
|
||||
|
||||
// Check TemplateFunctions registry first
|
||||
$functions = new TemplateFunctions(
|
||||
$this->container,
|
||||
AssetSlotFunction::class,
|
||||
LazyComponentFunction::class,
|
||||
UrlFunction::class,
|
||||
ViteTagsFunction::class
|
||||
);
|
||||
|
||||
if ($functions->has($functionName)) {
|
||||
$function = $functions->get($functionName);
|
||||
$args = $this->parseParams($params, $allVariables);
|
||||
if (is_callable($function)) {
|
||||
return $function(...$args);
|
||||
}
|
||||
}
|
||||
|
||||
// Check allowed template functions
|
||||
if (!in_array($functionName, $this->allowedTemplateFunctions)) {
|
||||
return $matches[0]; // Return unchanged if not allowed
|
||||
}
|
||||
|
||||
try {
|
||||
$args = $this->parseParams($params, $allVariables);
|
||||
|
||||
// Custom template functions
|
||||
if (method_exists($this, 'function_' . $functionName)) {
|
||||
$result = $this->{'function_' . $functionName}(...$args);
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Standard PHP functions (limited)
|
||||
if (function_exists($functionName)) {
|
||||
$result = $functionName(...$args);
|
||||
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return $matches[0]; // Return unchanged on error
|
||||
}
|
||||
|
||||
return $matches[0];
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate expression against dangerous patterns
|
||||
*/
|
||||
private function validateExpression(string $expression): void
|
||||
{
|
||||
$dangerousPatterns = [
|
||||
'/eval\s*\(/i',
|
||||
'/exec\s*\(/i',
|
||||
'/system\s*\(/i',
|
||||
'/shell_exec\s*\(/i',
|
||||
'/passthru\s*\(/i',
|
||||
'/proc_open\s*\(/i',
|
||||
'/popen\s*\(/i',
|
||||
'/`.*`/', // Backticks
|
||||
'/\$_(GET|POST|REQUEST|COOKIE|SERVER|ENV|FILES)/i', // Superglobals
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $expression)) {
|
||||
throw PlaceholderException::invalidExpression(
|
||||
$expression,
|
||||
'Potentially dangerous expression detected'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a literal value (string, number, boolean)
|
||||
*/
|
||||
private function evaluateLiteral(string $expression): mixed
|
||||
{
|
||||
$expression = trim($expression);
|
||||
|
||||
// String literals
|
||||
if (preg_match('/^["\'](.*)["\']$/', $expression, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if (is_numeric($expression)) {
|
||||
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
|
||||
}
|
||||
|
||||
// Boolean literals
|
||||
if ($expression === 'true') return true;
|
||||
if ($expression === 'false') return false;
|
||||
if ($expression === 'null') return null;
|
||||
|
||||
// Return as string if nothing else matches
|
||||
return $expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value for logging (safe for error_log)
|
||||
*/
|
||||
private function formatValueForLog(mixed $value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return strlen($value) > 50 ? substr($value, 0, 50) . '...' : $value;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
return 'array[' . count($value) . ']';
|
||||
}
|
||||
if (is_object($value)) {
|
||||
return get_class($value);
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process raw HTML placeholders: {{{ expression }}}
|
||||
* Supports: {{{ $data_table }}}, {{{ data_table }}}, {{{ $pagination['first_url'] }}}
|
||||
*/
|
||||
private function processRawPlaceholders(string $html, array $allVariables): string
|
||||
{
|
||||
$debugMode = getenv('APP_DEBUG') === 'true';
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{{\\s*(\\$?[a-zA-Z0-9_]+(?:\\[[^\\]]+\\])*)\\s*}}}/',
|
||||
function ($matches) use ($allVariables, $debugMode) {
|
||||
$expression = trim($matches[1]);
|
||||
|
||||
try {
|
||||
$this->validateExpression($expression);
|
||||
|
||||
// Handle fallback syntax
|
||||
if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $fallbackMatches)) {
|
||||
$primaryExpression = trim($fallbackMatches[1]);
|
||||
$fallbackExpression = trim($fallbackMatches[2]);
|
||||
|
||||
try {
|
||||
$this->validateExpression($primaryExpression);
|
||||
$value = $this->evaluator->evaluate($primaryExpression, $allVariables);
|
||||
if ($value !== null) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] RAW SUCCESS: {$primaryExpression} -> " . $this->formatValueForLog($value));
|
||||
}
|
||||
return $this->formatter->formatRaw($value);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] RAW FAILED: {$primaryExpression} - {$e->getMessage()}, using fallback");
|
||||
}
|
||||
}
|
||||
|
||||
$fallbackValue = $this->evaluateLiteral($fallbackExpression);
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] RAW FALLBACK: Using '{$fallbackExpression}' -> " . $this->formatValueForLog($fallbackValue));
|
||||
}
|
||||
return $this->formatter->formatRaw($fallbackValue);
|
||||
}
|
||||
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
|
||||
if ($debugMode && str_contains($expression, 'table')) {
|
||||
error_log("[Placeholder] RAW: expression={$expression}, type=" . gettype($value) . ", class=" . (is_object($value) ? get_class($value) : 'N/A') . ", isRawHtml=" . ($value instanceof RawHtml ? 'yes' : 'no'));
|
||||
}
|
||||
|
||||
$formatted = $this->formatter->formatRaw($value);
|
||||
|
||||
if ($debugMode && str_contains($expression, 'table')) {
|
||||
error_log("[Placeholder] RAW: formatted length=" . strlen($formatted) . ", starts with <table=" . (str_starts_with($formatted, '<table') ? 'yes' : 'no'));
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
|
||||
} catch (PlaceholderException $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] RAW ERROR: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
return '';
|
||||
} catch (\Throwable $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] RAW EXCEPTION: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in attributes without escaping (for URLs in href attributes)
|
||||
*/
|
||||
private function processPlaceholdersRaw(string $html, RenderContext $context): string
|
||||
{
|
||||
$allVariables = $context->getAllVariables();
|
||||
|
||||
// Process both {{{ }}} and {{ }} placeholders, but don't escape the result
|
||||
// First handle {{{ }}} placeholders
|
||||
$html = preg_replace_callback(
|
||||
'/{{{\\s*([^}]+?)\\s*}}}/',
|
||||
function ($matches) use ($allVariables) {
|
||||
$expression = trim($matches[1]);
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
return $this->formatter->formatRaw($value);
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
// Then handle {{ }} placeholders (but don't escape)
|
||||
// Use the same logic as processStandardPlaceholders but without escaping
|
||||
return preg_replace_callback(
|
||||
'/{{\\s*([^}]+?)\\s*}}/',
|
||||
function ($matches) use ($allVariables) {
|
||||
$expression = trim($matches[1]);
|
||||
|
||||
// Check for method call: expression(params)
|
||||
if (preg_match('/^(.+?)\\(\\s*([^)]*)\\s*\\)$/', $expression, $methodMatches)) {
|
||||
$methodExpr = trim($methodMatches[1]);
|
||||
$params = trim($methodMatches[2]);
|
||||
$value = $this->processMethodCall($methodExpr, $params, $allVariables);
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
// Try to resolve array bracket notation first (e.g., $pagination['first_url'] or pagination['first_url'])
|
||||
// Pattern: $var['key'] or var['key'] or $var["key"] or var["key"]
|
||||
if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[(["\']?)([^"\'\]]+)\\2\\]$/', $expression, $arrayMatches)) {
|
||||
$varName = $arrayMatches[1];
|
||||
$key = $arrayMatches[3];
|
||||
|
||||
// Check if variable exists in context
|
||||
if (array_key_exists($varName, $allVariables)) {
|
||||
$value = $allVariables[$varName];
|
||||
|
||||
// Handle numeric key
|
||||
if (is_numeric($key)) {
|
||||
$key = (int) $key;
|
||||
}
|
||||
|
||||
// Access array or object property
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$result = $value[$key];
|
||||
// Convert to string without escaping
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return '';
|
||||
}
|
||||
return (string) $result;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
if (isset($value->$key)) {
|
||||
$result = $value->$key;
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return '';
|
||||
}
|
||||
return (string) $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Try ExpressionEvaluator as fallback
|
||||
try {
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
|
||||
// Convert to string without escaping
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if ($value instanceof RawHtml) {
|
||||
return $value->content;
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
} catch (\Throwable $e) {
|
||||
// If evaluation fails, return original placeholder
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("PlaceholderTransformer::processPlaceholdersRaw: Failed to evaluate '{$expression}': " . $e->getMessage());
|
||||
}
|
||||
return $matches[0];
|
||||
}
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process standard placeholders: {{ expression }}
|
||||
*/
|
||||
private function processStandardPlaceholders(string $html, array $allVariables): string
|
||||
{
|
||||
// Pattern matches: {{ expression }} where expression can contain:
|
||||
// - Variables: $var, var
|
||||
// - Array access: $var['key'], $var["key"], $var[0]
|
||||
// - Dot notation: var.property
|
||||
// - Method calls: object.method()
|
||||
// Note: We need to handle both $var['key'] and var['key'] syntax
|
||||
|
||||
// Debug: Log available variables (only first time)
|
||||
static $logged = false;
|
||||
if (!$logged && !empty($allVariables)) {
|
||||
error_log("PlaceholderTransformer: Available variables: " . implode(', ', array_keys($allVariables)));
|
||||
$logged = true;
|
||||
}
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{\\s*([^}]+?)\\s*}}/',
|
||||
function ($matches) use ($allVariables) {
|
||||
$expression = trim($matches[1]);
|
||||
|
||||
// Debug: Log expression being processed
|
||||
if (str_contains($expression, 'stack') || str_contains($expression, 'container')) {
|
||||
error_log("PlaceholderTransformer: Processing expression: {$expression}");
|
||||
error_log("PlaceholderTransformer: Available vars: " . implode(', ', array_keys($allVariables)));
|
||||
}
|
||||
|
||||
// Check for method call: expression(params)
|
||||
if (preg_match('/^(.+?)\\(\\s*([^)]*)\\s*\\)$/', $expression, $methodMatches)) {
|
||||
$methodExpr = trim($methodMatches[1]);
|
||||
$params = trim($methodMatches[2]);
|
||||
return $this->processMethodCall($methodExpr, $params, $allVariables);
|
||||
}
|
||||
|
||||
// Try to resolve array bracket notation first (e.g., $container['id'] or container['id'])
|
||||
// This handles cases where ExpressionEvaluator might not support nested array access
|
||||
// Pattern: $var['key'] or var['key'] or $var["key"] or var["key"]
|
||||
if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[(["\']?)([^"\'\]]+)\\2\\]$/', $expression, $arrayMatches)) {
|
||||
$varName = $arrayMatches[1];
|
||||
$key = $arrayMatches[3];
|
||||
|
||||
// Check if variable exists in context
|
||||
if (array_key_exists($varName, $allVariables)) {
|
||||
$value = $allVariables[$varName];
|
||||
|
||||
// Handle numeric key
|
||||
if (is_numeric($key)) {
|
||||
$key = (int) $key;
|
||||
}
|
||||
|
||||
// Access array or object property
|
||||
if (is_array($value)) {
|
||||
if (array_key_exists($key, $value)) {
|
||||
return $this->formatEscapedValue($value[$key]);
|
||||
}
|
||||
} elseif (is_object($value)) {
|
||||
// Try direct property access
|
||||
if (isset($value->$key)) {
|
||||
return $this->formatEscapedValue($value->$key);
|
||||
}
|
||||
// Try toArray() for Value Objects
|
||||
if (method_exists($value, 'toArray')) {
|
||||
$array = $value->toArray();
|
||||
if (is_array($array) && array_key_exists($key, $array)) {
|
||||
return $this->formatEscapedValue($array[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If variable not found or key doesn't exist, fall through to ExpressionEvaluator
|
||||
}
|
||||
|
||||
// Try dot notation: $stack.display_name or stack.display_name
|
||||
// Remove leading $ for variable lookup
|
||||
$varNameForLookup = ltrim($expression, '$');
|
||||
if (str_contains($varNameForLookup, '.') && !str_contains($varNameForLookup, '[')) {
|
||||
$parts = explode('.', $varNameForLookup, 2);
|
||||
$varName = trim($parts[0]);
|
||||
$property = trim($parts[1]);
|
||||
|
||||
if (array_key_exists($varName, $allVariables)) {
|
||||
$obj = $allVariables[$varName];
|
||||
|
||||
if (is_array($obj) && array_key_exists($property, $obj)) {
|
||||
return $this->formatEscapedValue($obj[$property]);
|
||||
}
|
||||
|
||||
if (is_object($obj)) {
|
||||
if (isset($obj->$property)) {
|
||||
return $this->formatEscapedValue($obj->$property);
|
||||
}
|
||||
if (method_exists($obj, 'toArray')) {
|
||||
$array = $obj->toArray();
|
||||
if (is_array($array) && array_key_exists($property, $array)) {
|
||||
return $this->formatEscapedValue($array[$property]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle fallback syntax: {{ $var ?? 'default' }}
|
||||
$debugMode = getenv('APP_DEBUG') === 'true';
|
||||
if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $fallbackMatches)) {
|
||||
$primaryExpression = trim($fallbackMatches[1]);
|
||||
$fallbackExpression = trim($fallbackMatches[2]);
|
||||
|
||||
try {
|
||||
$this->validateExpression($primaryExpression);
|
||||
$value = $this->evaluator->evaluate($primaryExpression, $allVariables);
|
||||
if ($value !== null) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] SUCCESS: {$primaryExpression} -> " . $this->formatValueForLog($value));
|
||||
}
|
||||
return $this->formatEscapedValue($value);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] FAILED: {$primaryExpression} - {$e->getMessage()}, using fallback");
|
||||
}
|
||||
}
|
||||
|
||||
// Use fallback
|
||||
$fallbackValue = $this->evaluateLiteral($fallbackExpression);
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] FALLBACK: Using '{$fallbackExpression}' -> " . $this->formatValueForLog($fallbackValue));
|
||||
}
|
||||
return $this->formatEscapedValue($fallbackValue);
|
||||
}
|
||||
|
||||
// Fallback to ExpressionEvaluator for other expressions
|
||||
// ExpressionEvaluator supports: $var['key'], $var->prop, var.property
|
||||
// For array access with $, keep the $; for simple variables, ExpressionEvaluator handles both
|
||||
try {
|
||||
$this->validateExpression($expression);
|
||||
$value = $this->evaluator->evaluate($expression, $allVariables);
|
||||
|
||||
if ($value === null) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] NULL: {$expression} - Variable not found or returned null");
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->formatEscapedValue($value);
|
||||
} catch (PlaceholderException $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] ERROR: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
return '';
|
||||
} catch (\Throwable $e) {
|
||||
if ($debugMode) {
|
||||
error_log("[Placeholder] EXCEPTION: {$expression} - {$e->getMessage()}");
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process method calls: {{ object.method(params) }}
|
||||
*/
|
||||
private function processMethodCall(string $expression, string $params, array $allVariables): string
|
||||
{
|
||||
$parts = explode('.', $expression);
|
||||
$methodName = array_pop($parts);
|
||||
$objectPath = implode('.', $parts);
|
||||
|
||||
// Resolve object
|
||||
$object = $this->evaluator->evaluate($objectPath, $allVariables);
|
||||
|
||||
if (!is_object($object)) {
|
||||
return '{{ ' . $expression . '() }}'; // Keep placeholder if object not found
|
||||
}
|
||||
|
||||
if (!method_exists($object, $methodName)) {
|
||||
return '{{ ' . $expression . '() }}'; // Keep placeholder if method not found
|
||||
}
|
||||
|
||||
try {
|
||||
$parsedParams = empty($params) ? [] : $this->parseParams($params, $allVariables);
|
||||
$result = $object->$methodName(...$parsedParams);
|
||||
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
} catch (\Throwable $e) {
|
||||
return '{{ ' . $expression . '() }}'; // Keep placeholder on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value for escaped output
|
||||
*/
|
||||
private function formatEscapedValue(mixed $value): string
|
||||
{
|
||||
return $this->formatter->formatEscaped($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value for raw output
|
||||
*/
|
||||
private function formatRawValue(mixed $value): string
|
||||
{
|
||||
return $this->formatter->formatRaw($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse parameters from a parameter string
|
||||
*/
|
||||
private function parseParams(string $paramsString, array $data): array
|
||||
{
|
||||
if (empty(trim($paramsString))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = [];
|
||||
$parts = $this->splitParams($paramsString);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
|
||||
// String literals: 'text' or "text"
|
||||
if (preg_match('/^[\'\"](.*)[\'\"]$/s', $part, $matches)) {
|
||||
$params[] = $matches[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if (is_numeric($part)) {
|
||||
$params[] = str_contains($part, '.') ? (float)$part : (int)$part;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if ($part === 'true') {
|
||||
$params[] = true;
|
||||
continue;
|
||||
}
|
||||
if ($part === 'false') {
|
||||
$params[] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Null
|
||||
if ($part === 'null') {
|
||||
$params[] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Variable references: $variable or paths: object.property
|
||||
if (str_starts_with($part, '$')) {
|
||||
$varName = substr($part, 1);
|
||||
if (array_key_exists($varName, $data)) {
|
||||
$params[] = $data[$varName];
|
||||
continue;
|
||||
}
|
||||
} elseif (str_contains($part, '.')) {
|
||||
$value = $this->evaluator->evaluate($part, $data);
|
||||
if ($value !== null) {
|
||||
$params[] = $value;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Simple variable names (without $ or .) resolve from template data
|
||||
$value = $this->evaluator->evaluate($part, $data);
|
||||
if ($value !== null) {
|
||||
$params[] = $value;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: treat as string
|
||||
$params[] = $part;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split parameter string into individual parameters
|
||||
*/
|
||||
private function splitParams(string $paramsString): array
|
||||
{
|
||||
$params = [];
|
||||
$current = '';
|
||||
$inQuotes = false;
|
||||
$quoteChar = null;
|
||||
$length = strlen($paramsString);
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$char = $paramsString[$i];
|
||||
|
||||
if (!$inQuotes && ($char === '"' || $char === "'")) {
|
||||
$inQuotes = true;
|
||||
$quoteChar = $char;
|
||||
$current .= $char;
|
||||
} elseif ($inQuotes && $char === $quoteChar) {
|
||||
$inQuotes = false;
|
||||
$quoteChar = null;
|
||||
$current .= $char;
|
||||
} elseif (!$inQuotes && $char === ',') {
|
||||
$params[] = trim($current);
|
||||
$current = '';
|
||||
} else {
|
||||
$current .= $char;
|
||||
}
|
||||
}
|
||||
|
||||
if ($current !== '') {
|
||||
$params[] = trim($current);
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom template functions
|
||||
*/
|
||||
private function function_format_date(string|DateTime $date, string $format = 'Y-m-d H:i:s'): string
|
||||
{
|
||||
if (is_string($date)) {
|
||||
$date = new \DateTimeImmutable($date, new DateTimeZone('Europe/Berlin'));
|
||||
}
|
||||
|
||||
return $date->format($format);
|
||||
}
|
||||
|
||||
private function function_format_currency(float $amount, string $currency = 'EUR'): string
|
||||
{
|
||||
return number_format($amount, 2, ',', '.') . ' ' . $currency;
|
||||
}
|
||||
|
||||
private function function_format_filesize(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$factor = floor((strlen((string)$bytes) - 1) / 3);
|
||||
|
||||
return sprintf("%.1f %s", $bytes / pow(1024, $factor), $units[$factor]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
{
|
||||
/** @var array<ElementNode> Elements to transform */
|
||||
private array $xComponents = [];
|
||||
|
||||
/** @var array<string, bool> Track processed components to avoid duplicates */
|
||||
private array $processedComponents = [];
|
||||
|
||||
/** @var bool Flag to prevent infinite loops in retry logic */
|
||||
private bool $hasRetried = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComponentRegistryInterface $componentRegistry,
|
||||
@@ -47,21 +53,103 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
{
|
||||
// Reset state
|
||||
$this->xComponents = [];
|
||||
$this->processedComponents = [];
|
||||
$this->hasRetried = false;
|
||||
|
||||
// Phase 1: Find all x-components (visitor pattern)
|
||||
$document->accept($this);
|
||||
|
||||
// Debug: Log number of components found in first pass
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
$componentCount = count($this->xComponents);
|
||||
$buttonCount = count(array_filter($this->xComponents, fn($c) => str_contains($c->getTagName(), 'button')));
|
||||
error_log("XComponentTransformer: Found {$componentCount} components in first pass ({$buttonCount} buttons)");
|
||||
}
|
||||
|
||||
// Phase 2: Transform each x-component
|
||||
foreach ($this->xComponents as $xComponent) {
|
||||
$componentId = spl_object_hash($xComponent);
|
||||
if (isset($this->processedComponents[$componentId])) {
|
||||
continue; // Skip already processed components
|
||||
}
|
||||
|
||||
try {
|
||||
$this->transformXComponent($xComponent);
|
||||
$this->transformXComponent($xComponent, $context);
|
||||
$this->processedComponents[$componentId] = true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->handleTransformError($xComponent, $e);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Safety check - are there still x-components in the AST?
|
||||
// This can happen if ForTransformer added new elements after our first pass
|
||||
if (!$this->hasRetried) {
|
||||
$remainingComponents = $this->findRemainingComponents($document);
|
||||
|
||||
if (!empty($remainingComponents)) {
|
||||
// Debug: Log remaining components found
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
$remainingCount = count($remainingComponents);
|
||||
$remainingButtonCount = count(array_filter($remainingComponents, fn($c) => str_contains($c->getTagName(), 'button')));
|
||||
error_log("XComponentTransformer: Found {$remainingCount} remaining components after first pass ({$remainingButtonCount} buttons) - processing retry");
|
||||
}
|
||||
|
||||
// Process remaining components (max 1 retry to avoid infinite loops)
|
||||
$this->hasRetried = true;
|
||||
foreach ($remainingComponents as $component) {
|
||||
$componentId = spl_object_hash($component);
|
||||
if (!isset($this->processedComponents[$componentId])) {
|
||||
try {
|
||||
$this->transformXComponent($component, $context);
|
||||
$this->processedComponents[$componentId] = true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->handleTransformError($component, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find remaining x-components in the AST (for retry logic)
|
||||
*
|
||||
* @return array<ElementNode>
|
||||
*/
|
||||
private function findRemainingComponents(DocumentNode $document): array
|
||||
{
|
||||
$remainingComponents = [];
|
||||
$visitor = new class($remainingComponents) implements NodeVisitor {
|
||||
public function __construct(private array &$components) {}
|
||||
|
||||
public function visitDocument(DocumentNode $node): void
|
||||
{
|
||||
// Continue traversal
|
||||
}
|
||||
|
||||
public function visitElement(ElementNode $node): void
|
||||
{
|
||||
if (str_starts_with(strtolower($node->getTagName()), 'x-')) {
|
||||
$this->components[] = $node;
|
||||
}
|
||||
}
|
||||
|
||||
public function visitText(TextNode $node): void
|
||||
{
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
public function visitComment(CommentNode $node): void
|
||||
{
|
||||
// Nothing to do
|
||||
}
|
||||
};
|
||||
|
||||
$document->accept($visitor);
|
||||
return $remainingComponents;
|
||||
}
|
||||
|
||||
public function visitDocument(DocumentNode $node): void
|
||||
{
|
||||
@@ -73,7 +161,12 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
// Check if this is an x-component that needs transformation
|
||||
if ($this->isXComponent($node)) {
|
||||
$this->xComponents[] = $node;
|
||||
// Don't traverse children - x-components are self-contained
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue traversal to find nested x-components
|
||||
// This ensures we find x-components even if they're inside elements with if attributes
|
||||
}
|
||||
|
||||
public function visitText(TextNode $node): void
|
||||
@@ -93,8 +186,10 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
|
||||
/**
|
||||
* Transform single x-component with auto-detection
|
||||
*
|
||||
* Note: Placeholders are already processed by PlaceholderTransformer before this runs
|
||||
*/
|
||||
private function transformXComponent(ElementNode $element): void
|
||||
private function transformXComponent(ElementNode $element, RenderContext $context): void
|
||||
{
|
||||
// Extract component name: <x-datatable> → "datatable"
|
||||
$componentName = $this->extractComponentName($element);
|
||||
@@ -118,7 +213,7 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
// Auto-detect component type based on interface
|
||||
if (is_subclass_of($componentClass, StaticComponent::class)) {
|
||||
// Process as StaticComponent (Server-side rendered)
|
||||
$html = $this->renderStaticComponent($element, $componentClass);
|
||||
$html = $this->renderStaticComponent($element, $componentClass, $context);
|
||||
$this->replaceWithHtml($element, $html);
|
||||
} else {
|
||||
// Process as LiveComponent (Interactive/Stateful)
|
||||
@@ -158,20 +253,61 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
*
|
||||
* @param class-string<StaticComponent> $componentClass
|
||||
*/
|
||||
private function renderStaticComponent(ElementNode $element, string $componentClass): string
|
||||
private function renderStaticComponent(ElementNode $element, string $componentClass, RenderContext $context): string
|
||||
{
|
||||
// Get element content and attributes
|
||||
$content = $element->getTextContent();
|
||||
// Extract content as HTML (not text) - placeholders already processed by PlaceholderTransformer
|
||||
// This ensures RawHtmlNode is correctly handled (e.g., {{{ $data_table }}})
|
||||
$content = $this->extractContentAsHtml($element);
|
||||
|
||||
// Extract attributes - placeholders already processed by PlaceholderTransformer
|
||||
$attributes = $this->extractAttributesForStaticComponent($element);
|
||||
|
||||
// Process placeholders in attributes as fallback (only if still present)
|
||||
// PlaceholderTransformer should have already processed them, but we check for edge cases
|
||||
$processedAttributes = $this->processAttributeContent($attributes, $context);
|
||||
|
||||
// Render via StaticComponentRenderer
|
||||
return $this->staticComponentRenderer->render(
|
||||
$componentClass,
|
||||
$content,
|
||||
$attributes
|
||||
$processedAttributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content as HTML from element, preserving RawHtmlNode content
|
||||
* This is needed because RawHtmlNode contains already-processed HTML (e.g., from {{{ }}} placeholders)
|
||||
*/
|
||||
private function extractContentAsHtml(ElementNode $element): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
foreach ($element->getChildren() as $child) {
|
||||
if ($child instanceof \App\Framework\View\Dom\RawHtmlNode) {
|
||||
// RawHtmlNode contains already processed HTML (from {{{ }}} placeholders)
|
||||
// Output directly without escaping
|
||||
$html .= $child->getHtml();
|
||||
} elseif ($child instanceof ElementNode) {
|
||||
// Render element to HTML using HtmlRenderer
|
||||
$renderer = new \App\Framework\View\Dom\Renderer\HtmlRenderer();
|
||||
$html .= $renderer->render($child);
|
||||
} elseif ($child instanceof TextNode) {
|
||||
// Text already processed by PlaceholderTransformer
|
||||
// Escape for safety (should already be escaped, but double-check)
|
||||
$text = $child->getTextContent();
|
||||
// Only escape if it doesn't look like already-processed HTML
|
||||
if (!str_starts_with(trim($text), '<')) {
|
||||
$html .= htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
} else {
|
||||
// Already HTML, output directly
|
||||
$html .= $text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace element with parsed HTML nodes
|
||||
*/
|
||||
@@ -183,6 +319,10 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
// Get parent node
|
||||
$parent = $element->getParent();
|
||||
if (!$parent) {
|
||||
// Debug: Log missing parent
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("XComponentTransformer: replaceWithHtml: Element {$element->getTagName()} has no parent");
|
||||
}
|
||||
return; // No parent, can't replace
|
||||
}
|
||||
|
||||
@@ -196,7 +336,36 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
$position = array_search($element, $children, true);
|
||||
|
||||
if ($position === false) {
|
||||
return; // Element isn't found in the parent
|
||||
// Element not found in parent's children - try alternative replacement
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("XComponentTransformer: replaceWithHtml: Element {$element->getTagName()} not found in parent's children (parent: {$parent->getNodeName()}, children count: " . count($children) . ")");
|
||||
}
|
||||
|
||||
// Try direct remove/append as fallback
|
||||
try {
|
||||
$parent->removeChild($element);
|
||||
foreach ($componentDocument->getChildren() as $newChild) {
|
||||
$clonedChild = $newChild->clone();
|
||||
$clonedChild->setParent($parent);
|
||||
$parent->appendChild($clonedChild);
|
||||
}
|
||||
|
||||
// Debug: Log successful alternative replacement
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("XComponentTransformer: replaceWithHtml: Successfully replaced {$element->getTagName()} using alternative method");
|
||||
}
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("XComponentTransformer: replaceWithHtml: Alternative replacement failed for {$element->getTagName()}: " . $e->getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log found element
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($element->getTagName(), 'button')) {
|
||||
error_log("XComponentTransformer: replaceWithHtml: Found element {$element->getTagName()} at position {$position} in parent {$parent->getNodeName()}");
|
||||
}
|
||||
|
||||
// Remove original element's parent reference
|
||||
@@ -224,6 +393,11 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
|
||||
// Update parent's children array
|
||||
$childrenProperty->setValue($parent, $newChildren);
|
||||
|
||||
// Debug: Log successful replacement
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($element->getTagName(), 'button')) {
|
||||
error_log("XComponentTransformer: replaceWithHtml: Successfully replaced {$element->getTagName()} with " . count($componentDocument->getChildren()) . " children");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,15 +448,22 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
|
||||
// Try to parse JSON strings (for arrays/objects passed as attributes)
|
||||
// This allows templates to pass complex data like: navigation-menu='{"sections":[...]}'
|
||||
if (is_string($value) && (str_starts_with($value, '[') || str_starts_with($value, '{'))) {
|
||||
try {
|
||||
$decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (is_array($decoded)) {
|
||||
$attributes[$attr->name] = $decoded;
|
||||
continue;
|
||||
// Also handles JSON created by PlaceholderTransformer for array values
|
||||
if (is_string($value)) {
|
||||
// Decode HTML entities first (in case they were encoded)
|
||||
$decodedValue = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Try to parse as JSON if it looks like JSON (starts with [ or {)
|
||||
if (str_starts_with(trim($decodedValue), '[') || str_starts_with(trim($decodedValue), '{')) {
|
||||
try {
|
||||
$jsonDecoded = json_decode($decodedValue, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (is_array($jsonDecoded)) {
|
||||
$attributes[$attr->name] = $jsonDecoded;
|
||||
continue;
|
||||
}
|
||||
} catch (\JsonException) {
|
||||
// Not valid JSON, use as string
|
||||
}
|
||||
} catch (\JsonException) {
|
||||
// Not valid JSON, use as string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +473,123 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process attribute content for placeholders before passing to component renderer
|
||||
* Note: PlaceholderTransformer should have already processed placeholders in attributes,
|
||||
* but we check for any remaining placeholders as a fallback
|
||||
*/
|
||||
private function processAttributeContent(array $attributes, RenderContext $context): array
|
||||
{
|
||||
$processedAttributes = [];
|
||||
$allVariables = $context->getAllVariables();
|
||||
|
||||
foreach ($attributes as $key => $value) {
|
||||
// Process placeholders in attribute values (only if still present)
|
||||
// PlaceholderTransformer should have already processed them, but check for edge cases
|
||||
if (is_string($value) && str_contains($value, '{{')) {
|
||||
// For href attributes, use raw processing to avoid double-escaping URLs
|
||||
if ($key === 'href') {
|
||||
$processedAttributes[$key] = $this->processPlaceholdersInAttributeRaw($value, $allVariables);
|
||||
} else {
|
||||
// For complex attributes (like arrays), try to evaluate as expression first
|
||||
$result = $this->processPlaceholdersInAttributeAsValue($value, $allVariables);
|
||||
$processedAttributes[$key] = $result;
|
||||
}
|
||||
} else {
|
||||
$processedAttributes[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $processedAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in a single attribute value, preserving arrays and objects
|
||||
*/
|
||||
private function processPlaceholdersInAttributeAsValue(string $value, array $variables): mixed
|
||||
{
|
||||
// Use ExpressionEvaluator to evaluate placeholders
|
||||
$evaluator = new \App\Framework\Template\Expression\ExpressionEvaluator();
|
||||
|
||||
// Check if the entire value is a single placeholder (e.g., "{{$pagination}}")
|
||||
if (preg_match('/^{{\s*([^}]+?)\s*}}$/', $value, $matches)) {
|
||||
$expression = trim($matches[1]);
|
||||
try {
|
||||
$result = $evaluator->evaluate($expression, $variables);
|
||||
// Return the result directly (preserve arrays, objects, etc.)
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
// If evaluation fails, return original placeholder as string
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("XComponentTransformer: Failed to evaluate '{$expression}': " . $e->getMessage());
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple placeholders or mixed content - process as string
|
||||
return preg_replace_callback(
|
||||
'/{{\s*([^}]+?)\s*}}/',
|
||||
function ($matches) use ($evaluator, $variables) {
|
||||
$expression = trim($matches[1]);
|
||||
try {
|
||||
$result = $evaluator->evaluate($expression, $variables);
|
||||
// Convert result to string for attribute value
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return '';
|
||||
}
|
||||
// Escape for attribute value (except URLs which are handled separately)
|
||||
return htmlspecialchars((string) $result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
} catch (\Throwable $e) {
|
||||
// If evaluation fails, return original placeholder
|
||||
// Log error in debug mode
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("XComponentTransformer: Failed to evaluate '{$expression}': " . $e->getMessage());
|
||||
}
|
||||
return $matches[0];
|
||||
}
|
||||
},
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in href attribute values without escaping (URLs)
|
||||
*/
|
||||
private function processPlaceholdersInAttributeRaw(string $value, array $variables): string
|
||||
{
|
||||
// Use ExpressionEvaluator to evaluate placeholders
|
||||
$evaluator = new \App\Framework\Template\Expression\ExpressionEvaluator();
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{\s*([^}]+?)\s*}}/',
|
||||
function ($matches) use ($evaluator, $variables) {
|
||||
$expression = trim($matches[1]);
|
||||
try {
|
||||
$result = $evaluator->evaluate($expression, $variables);
|
||||
// Convert result to string for URL (no escaping - will be escaped by setAttribute)
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return '';
|
||||
}
|
||||
return (string) $result;
|
||||
} catch (\Throwable $e) {
|
||||
// If evaluation fails, return original placeholder
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
error_log("XComponentTransformer: Failed to evaluate '{$expression}': " . $e->getMessage());
|
||||
}
|
||||
return $matches[0];
|
||||
}
|
||||
},
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type coercion for prop values (LiveComponents only)
|
||||
*/
|
||||
|
||||
@@ -276,16 +276,18 @@ final readonly class DomWrapper
|
||||
/**
|
||||
* Setzt Attribut für ein Element
|
||||
*/
|
||||
public function setAttribute(Element $element, string $name, string $value): void
|
||||
public function setAttribute(Element $element, string|\App\Framework\View\ValueObjects\DataAttributeInterface $name, ?string $value = null): void
|
||||
{
|
||||
$element->setAttribute($name, $value);
|
||||
$nameString = \App\Framework\View\ValueObjects\DataAttributeHelper::toString($name);
|
||||
$element->setAttribute($nameString, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt Attribut von einem Element
|
||||
*/
|
||||
public function removeAttribute(Element $element, string $name): void
|
||||
public function removeAttribute(Element $element, string|\App\Framework\View\ValueObjects\DataAttributeInterface $name): void
|
||||
{
|
||||
$element->removeAttribute($name);
|
||||
$nameString = \App\Framework\View\ValueObjects\DataAttributeHelper::toString($name);
|
||||
$element->removeAttribute($nameString);
|
||||
}
|
||||
}
|
||||
|
||||
63
src/Framework/View/Exceptions/PlaceholderException.php
Normal file
63
src/Framework/View/Exceptions/PlaceholderException.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Exceptions;
|
||||
|
||||
use App\Framework\Exception\Core\TemplateErrorCode;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Placeholder Exception für Placeholder-Verarbeitungsfehler
|
||||
*
|
||||
* Wird geworfen wenn:
|
||||
* - Placeholder-Variablen nicht gefunden werden können
|
||||
* - Placeholder-Ausdrücke ungültig sind
|
||||
* - Placeholder-Auswertung fehlschlägt
|
||||
*/
|
||||
final class PlaceholderException extends FrameworkException
|
||||
{
|
||||
/**
|
||||
* Variable nicht im Scope gefunden
|
||||
*/
|
||||
public static function variableNotFound(string $varName, array $availableVars): self
|
||||
{
|
||||
$availableList = implode(', ', array_keys($availableVars));
|
||||
|
||||
return self::create(
|
||||
TemplateErrorCode::INVALID_PLACEHOLDER,
|
||||
"Placeholder variable '{$varName}' not found. Available variables: {$availableList}"
|
||||
)->withData([
|
||||
'variable' => $varName,
|
||||
'available_variables' => array_keys($availableVars),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ungültiger Placeholder-Ausdruck
|
||||
*/
|
||||
public static function invalidExpression(string $expression, string $reason): self
|
||||
{
|
||||
return self::create(
|
||||
TemplateErrorCode::INVALID_PLACEHOLDER,
|
||||
"Invalid placeholder expression '{$expression}': {$reason}"
|
||||
)->withData([
|
||||
'expression' => $expression,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder-Auswertung fehlgeschlagen
|
||||
*/
|
||||
public static function evaluationFailed(string $expression, \Throwable $previous): self
|
||||
{
|
||||
return self::create(
|
||||
TemplateErrorCode::INVALID_PLACEHOLDER,
|
||||
"Placeholder evaluation failed for '{$expression}': {$previous->getMessage()}"
|
||||
)->withData([
|
||||
'expression' => $expression,
|
||||
])->withPrevious($previous);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace App\Framework\View;
|
||||
use App\Framework\Http\Session\FormIdGenerator;
|
||||
use App\Framework\View\ValueObjects\FormElement;
|
||||
use App\Framework\View\ValueObjects\FormId;
|
||||
use App\Framework\View\ValueObjects\HtmlAttributes;
|
||||
use App\Framework\View\ValueObjects\HtmlElement;
|
||||
use App\Framework\View\ValueObjects\StandardHtmlElement;
|
||||
use App\Framework\View\ValueObjects\TagName;
|
||||
|
||||
final readonly class FormBuilder
|
||||
{
|
||||
@@ -68,6 +71,17 @@ final readonly class FormBuilder
|
||||
return new self($newForm, $this->formIdGenerator, $this->elements);
|
||||
}
|
||||
|
||||
public function withEnctype(string $enctype): self
|
||||
{
|
||||
$newForm = new FormElement(
|
||||
$this->form->tag,
|
||||
$this->form->attributes->with('enctype', $enctype),
|
||||
$this->form->content
|
||||
);
|
||||
|
||||
return new self($newForm, $this->formIdGenerator, $this->elements);
|
||||
}
|
||||
|
||||
public function withClass(string $class): self
|
||||
{
|
||||
$newForm = $this->form->withClass($class);
|
||||
@@ -80,6 +94,16 @@ final readonly class FormBuilder
|
||||
return new self($this->form, $this->formIdGenerator, [...$this->elements, $element]);
|
||||
}
|
||||
|
||||
public function addRawHtml(string $html): self
|
||||
{
|
||||
// Use StandardHtmlElement with a div wrapper for raw HTML
|
||||
$rawElement = StandardHtmlElement::create(
|
||||
TagName::DIV,
|
||||
HtmlAttributes::empty()
|
||||
)->withContent($html);
|
||||
return new self($this->form, $this->formIdGenerator, [...$this->elements, $rawElement]);
|
||||
}
|
||||
|
||||
public function addTextInput(string $name, string $value = '', string $label = ''): self
|
||||
{
|
||||
$elements = [];
|
||||
|
||||
140
src/Framework/View/Formatting/ValueFormatter.php
Normal file
140
src/Framework/View/Formatting/ValueFormatter.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Formatting;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\LiveComponents\ComponentRegistry;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\View\RawHtml;
|
||||
use App\Framework\View\Table\Table;
|
||||
|
||||
/**
|
||||
* Zentrale Klasse für die Formatierung von Template-Werten
|
||||
*
|
||||
* Stellt sicher, dass RawHtml und andere spezielle Typen konsistent behandelt werden.
|
||||
* Verhindert Duplikation der Formatierungslogik über mehrere Klassen hinweg.
|
||||
*/
|
||||
final readonly class ValueFormatter
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Formatiert einen Wert für Raw-HTML-Ausgabe ({{{ }}})
|
||||
*
|
||||
* @param mixed $value Der zu formatierende Wert
|
||||
* @return string Formatierter HTML-String (nicht escaped)
|
||||
*/
|
||||
public function formatRaw(mixed $value): string
|
||||
{
|
||||
// RawHtml objects - output directly (check FIRST for performance and reliability)
|
||||
if ($value instanceof RawHtml) {
|
||||
return $value->content;
|
||||
}
|
||||
|
||||
// Table objects - render directly
|
||||
if ($value instanceof Table) {
|
||||
return $value->render();
|
||||
}
|
||||
|
||||
// LiveComponentContract - render with wrapper
|
||||
if ($value instanceof LiveComponentContract) {
|
||||
$componentRegistry = $this->container->get(ComponentRegistry::class);
|
||||
return $componentRegistry->renderWithWrapper($value);
|
||||
}
|
||||
|
||||
// HtmlElement objects - output directly
|
||||
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
// Strings output directly (NO escaping!)
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Arrays and complex objects
|
||||
if (is_array($value)) {
|
||||
return $this->handleArrayValue($value);
|
||||
}
|
||||
|
||||
if (is_object($value) && !method_exists($value, '__toString')) {
|
||||
return $this->handleObjectValue($value);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert einen Wert für escaped Ausgabe ({{ }})
|
||||
*
|
||||
* @param mixed $value Der zu formatierende Wert
|
||||
* @return string Formatierter HTML-String (escaped, außer für RawHtml)
|
||||
*/
|
||||
public function formatEscaped(mixed $value): string
|
||||
{
|
||||
// RawHtml objects - don't escape (check FIRST for performance and reliability)
|
||||
if ($value instanceof RawHtml) {
|
||||
return $value->content;
|
||||
}
|
||||
|
||||
// Table objects - render directly
|
||||
if ($value instanceof Table) {
|
||||
return $value->render();
|
||||
}
|
||||
|
||||
// LiveComponentContract - render with wrapper
|
||||
if ($value instanceof LiveComponentContract) {
|
||||
$componentRegistry = $this->container->get(ComponentRegistry::class);
|
||||
return $componentRegistry->renderWithWrapper($value);
|
||||
}
|
||||
|
||||
// HtmlElement objects - don't escape
|
||||
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
// Arrays and complex objects cannot be displayed directly
|
||||
if (is_array($value)) {
|
||||
return $this->handleArrayValue($value);
|
||||
}
|
||||
|
||||
if (is_object($value) && !method_exists($value, '__toString')) {
|
||||
return $this->handleObjectValue($value);
|
||||
}
|
||||
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle array values
|
||||
*/
|
||||
private function handleArrayValue(array $value): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// For arrays with only one element: return the element (escaped)
|
||||
if (count($value) === 1 && !is_array(reset($value)) && !is_object(reset($value))) {
|
||||
return htmlspecialchars((string)reset($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
// For simple arrays: show count
|
||||
return '[' . count($value) . ' items]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle object values
|
||||
*/
|
||||
private function handleObjectValue(object $value): string
|
||||
{
|
||||
$className = get_class($value);
|
||||
$shortName = substr($className, strrpos($className, '\\') + 1);
|
||||
return '[' . $shortName . ']';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
src/Framework/View/Functions/AssetSlotFunction.php
Normal file
43
src/Framework/View/Functions/AssetSlotFunction.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Functions;
|
||||
|
||||
use App\Domain\Asset\Repositories\AssetRepository;
|
||||
use App\Domain\Asset\Repositories\AssetSlotRepository;
|
||||
use App\Domain\Asset\Services\AssetSourceSetGenerator;
|
||||
use App\Framework\View\ComponentRenderer;
|
||||
|
||||
final readonly class AssetSlotFunction implements TemplateFunction
|
||||
{
|
||||
public string $functionName;
|
||||
|
||||
public function __construct(
|
||||
private AssetSlotRepository $assetSlotRepository,
|
||||
private AssetRepository $assetRepository,
|
||||
private AssetSourceSetGenerator $sourceSetGenerator,
|
||||
private ComponentRenderer $componentRenderer
|
||||
) {
|
||||
$this->functionName = 'assetslot';
|
||||
}
|
||||
|
||||
public function __invoke(string $slotName): string
|
||||
{
|
||||
$slot = $this->assetSlotRepository->findBySlotName($slotName);
|
||||
|
||||
if ($slot === null || $slot->assetId === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$asset = $this->assetRepository->findById(\App\Domain\Asset\ValueObjects\AssetId::fromString($slot->assetId));
|
||||
|
||||
if ($asset === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Generate Picture element with source sets
|
||||
return $this->sourceSetGenerator->generatePictureElement($asset, 'gallery');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Functions;
|
||||
|
||||
use App\Domain\Media\ImageSlotRepository;
|
||||
use App\Domain\Media\ImageSourceSetGenerator;
|
||||
use App\Framework\View\ComponentRenderer;
|
||||
|
||||
final readonly class ImageSlotFunction implements TemplateFunction
|
||||
{
|
||||
public string $functionName;
|
||||
|
||||
public function __construct(
|
||||
private ImageSlotRepository $imageSlotRepository,
|
||||
private ComponentRenderer $componentRenderer
|
||||
) {
|
||||
$this->functionName = 'imageslot';
|
||||
}
|
||||
|
||||
public function __invoke(string $slotName): string
|
||||
{
|
||||
$image = $this->imageSlotRepository->findBySlotName($slotName)->image;
|
||||
|
||||
$srcGen = new ImageSourceSetGenerator();
|
||||
|
||||
return $srcGen->generatePictureElement($image);
|
||||
|
||||
$data = [
|
||||
'image' => $image->filename,
|
||||
'alt' => $image->altText,
|
||||
];
|
||||
|
||||
return $this->componentRenderer->render('imageslot', $data);
|
||||
}
|
||||
}
|
||||
75
src/Framework/View/Functions/ViteTagsFunction.php
Normal file
75
src/Framework/View/Functions/ViteTagsFunction.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Functions;
|
||||
|
||||
use App\Framework\Vite\ViteService;
|
||||
|
||||
/**
|
||||
* Vite Tags Template Function
|
||||
*
|
||||
* Generates HTML tags for Vite assets (CSS and JS) using ViteService.
|
||||
* Supports both development (HMR) and production (manifest) modes.
|
||||
*/
|
||||
final readonly class ViteTagsFunction implements TemplateFunction
|
||||
{
|
||||
public function __construct(
|
||||
private ViteService $viteService
|
||||
) {
|
||||
$this->functionName = 'vite_tags';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Vite asset tags
|
||||
*
|
||||
* Supports multiple call patterns:
|
||||
* - vite_tags() - uses default entrypoints from config
|
||||
* - vite_tags('admin') - single entrypoint as string
|
||||
* - vite_tags('admin', 'main') - multiple entrypoints as variadic arguments
|
||||
* - vite_tags(['admin', 'main']) - entrypoints as array (if template parser supports it)
|
||||
*
|
||||
* @param string|array<string>|null ...$entrypoints Entrypoints (variadic)
|
||||
* @return string HTML string with link and script tags
|
||||
*/
|
||||
public function __invoke(string|array|null ...$entrypoints): string
|
||||
{
|
||||
// No arguments: use default from config
|
||||
if (empty($entrypoints)) {
|
||||
return $this->viteService->getTags();
|
||||
}
|
||||
|
||||
// Normalize arguments to array
|
||||
$normalized = [];
|
||||
foreach ($entrypoints as $arg) {
|
||||
if (is_array($arg)) {
|
||||
// If argument is already an array, merge it
|
||||
$normalized = array_merge($normalized, $arg);
|
||||
} elseif (is_string($arg)) {
|
||||
// Handle string that might be a JSON array representation
|
||||
if (str_starts_with($arg, '[') && str_ends_with($arg, ']')) {
|
||||
// Try to parse as JSON array
|
||||
$decoded = json_decode($arg, true);
|
||||
if (is_array($decoded)) {
|
||||
$normalized = array_merge($normalized, $decoded);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Single string argument (non-empty)
|
||||
if ($arg !== '') {
|
||||
$normalized[] = $arg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have normalized entrypoints, use them; otherwise use defaults
|
||||
if (!empty($normalized)) {
|
||||
return $this->viteService->getTags($normalized);
|
||||
}
|
||||
|
||||
return $this->viteService->getTags();
|
||||
}
|
||||
|
||||
public string $functionName;
|
||||
}
|
||||
|
||||
@@ -151,27 +151,67 @@ final class HtmlLexer
|
||||
|
||||
// Extract tag name for raw text handling
|
||||
$tagName = strtolower(substr($this->html, $tagNameStart, $this->position - $tagNameStart));
|
||||
|
||||
// Debug: Log if tag contains placeholders
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains(substr($this->html, $start), '{{$item')) {
|
||||
error_log("HtmlLexer::consumeOpeningTag: Processing tag starting at position {$start}: " . substr($this->html, $start, 100));
|
||||
}
|
||||
|
||||
// Consume attributes with proper quote handling
|
||||
// IMPORTANT: Must handle nested quotes in placeholders like {{$item['url']}}
|
||||
$inQuote = false;
|
||||
$quoteChar = '';
|
||||
$inPlaceholder = false;
|
||||
$placeholderDepth = 0;
|
||||
|
||||
while ($this->position < $this->length && $this->current() !== '>') {
|
||||
$char = $this->current();
|
||||
$nextChar = $this->peek(1);
|
||||
|
||||
// Handle quotes in attributes
|
||||
if (($char === '"' || $char === "'") && !$inQuote) {
|
||||
$inQuote = true;
|
||||
$quoteChar = $char;
|
||||
} elseif ($inQuote && $char === $quoteChar) {
|
||||
$inQuote = false;
|
||||
$quoteChar = '';
|
||||
// FIRST: Track placeholder boundaries {{ }}
|
||||
// This must be checked BEFORE quote handling to prevent premature quote closing
|
||||
if ($char === '{' && $nextChar === '{') {
|
||||
$inPlaceholder = true;
|
||||
$placeholderDepth++;
|
||||
$this->advance(); // Skip first {
|
||||
$this->advance(); // Skip second {
|
||||
continue; // Skip to next iteration WITHOUT advancing position
|
||||
}
|
||||
|
||||
if ($inPlaceholder && $char === '}' && $nextChar === '}') {
|
||||
$placeholderDepth--;
|
||||
if ($placeholderDepth === 0) {
|
||||
$inPlaceholder = false;
|
||||
}
|
||||
$this->advance(); // Skip first }
|
||||
$this->advance(); // Skip second }
|
||||
continue; // Skip to next iteration WITHOUT advancing position
|
||||
}
|
||||
|
||||
// SECOND: Handle quotes in attributes
|
||||
// Only process quotes if we're NOT inside a placeholder
|
||||
// This prevents nested quotes inside placeholders from closing the attribute value
|
||||
if (!$inPlaceholder) {
|
||||
if (($char === '"' || $char === "'") && !$inQuote) {
|
||||
$inQuote = true;
|
||||
$quoteChar = $char;
|
||||
} elseif ($inQuote && $char === $quoteChar) {
|
||||
// Check if quote is escaped
|
||||
if ($this->position > 0 && $this->html[$this->position - 1] === '\\') {
|
||||
$this->advance();
|
||||
continue;
|
||||
}
|
||||
$inQuote = false;
|
||||
$quoteChar = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Advance position - this is critical!
|
||||
// We must advance AFTER checking for placeholders and quotes
|
||||
$this->advance();
|
||||
|
||||
// Don't break on '>' inside quotes
|
||||
if ($this->current() === '>' && $inQuote) {
|
||||
if ($this->position < $this->length && $this->current() === '>' && $inQuote) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -193,7 +233,17 @@ final class HtmlLexer
|
||||
$this->currentTagName = $tagName;
|
||||
}
|
||||
|
||||
return new Token(substr($this->html, $start, $this->position - $start), TokenType::OPEN_TAG_START);
|
||||
$tokenContent = substr($this->html, $start, $this->position - $start);
|
||||
|
||||
// Debug: Log token content if it contains placeholders
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($tokenContent, '{{')) {
|
||||
error_log("HtmlLexer::consumeOpeningTag: Token content: " . substr($tokenContent, 0, 200));
|
||||
if (str_contains($tokenContent, '{{') && !str_contains($tokenContent, '$item')) {
|
||||
error_log("HtmlLexer::consumeOpeningTag: WARNING - Placeholder found but \$item is missing!");
|
||||
}
|
||||
}
|
||||
|
||||
return new Token($tokenContent, TokenType::OPEN_TAG_START);
|
||||
}
|
||||
|
||||
private function consumeClosingTag(int $start): Token
|
||||
|
||||
206
src/Framework/View/Linting/ComponentLinter.php
Normal file
206
src/Framework/View/Linting/ComponentLinter.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Linting;
|
||||
|
||||
/**
|
||||
* Component Linter
|
||||
*
|
||||
* Analysiert Templates auf:
|
||||
* - Direkte CSS-Klassen-Verwendung statt Components
|
||||
* - Deprecated admin-* Klassen
|
||||
* - Alte <component name="..."> Syntax
|
||||
* - Inkonsistente Component-Verwendung
|
||||
*/
|
||||
final readonly class ComponentLinter
|
||||
{
|
||||
/**
|
||||
* Lintet eine Template-Datei
|
||||
*
|
||||
* @param string $templatePath Pfad zur Template-Datei
|
||||
* @return array<int, array{type: string, message: string, line: int, suggestion?: string}>
|
||||
*/
|
||||
public function lint(string $templatePath): array
|
||||
{
|
||||
if (!file_exists($templatePath)) {
|
||||
return [
|
||||
[
|
||||
'type' => 'error',
|
||||
'message' => "Template file not found: {$templatePath}",
|
||||
'line' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$content = file_get_contents($templatePath);
|
||||
$issues = [];
|
||||
|
||||
// Prüfe auf direkte btn/card/badge Klassen
|
||||
$directClassIssues = $this->checkDirectClassUsage($content);
|
||||
$issues = array_merge($issues, $directClassIssues);
|
||||
|
||||
// Prüfe auf deprecated admin-* Klassen
|
||||
$deprecatedIssues = $this->checkDeprecatedClasses($content);
|
||||
$issues = array_merge($issues, $deprecatedIssues);
|
||||
|
||||
// Prüfe auf alte <component> Syntax
|
||||
$oldSyntaxIssues = $this->checkOldComponentSyntax($content);
|
||||
$issues = array_merge($issues, $oldSyntaxIssues);
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft auf direkte CSS-Klassen-Verwendung
|
||||
*
|
||||
* @param string $content Template-Inhalt
|
||||
* @return array<int, array{type: string, message: string, line: int, suggestion?: string}>
|
||||
*/
|
||||
private function checkDirectClassUsage(string $content): array
|
||||
{
|
||||
$issues = [];
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
// Pattern für direkte btn/card/badge Klassen (ohne -- Modifier)
|
||||
// Ignoriere spezielle Klassen wie stat-card, page-card etc.
|
||||
$patterns = [
|
||||
'btn' => 'button',
|
||||
'card' => 'card',
|
||||
'badge' => 'badge',
|
||||
];
|
||||
|
||||
// Klassen die ignoriert werden sollen (z.B. stat-card ist eine spezielle Card-Variante)
|
||||
$ignoredClasses = ['stat-card', 'page-card'];
|
||||
|
||||
foreach ($patterns as $class => $component) {
|
||||
// Suche nach class="... btn ..." oder class="btn ..."
|
||||
// Aber ignoriere wenn es eine der ignored classes ist
|
||||
$pattern = '/class=["\'][^"\']*\b' . preg_quote($class, '/') . '(?!--)/';
|
||||
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
$matchContent = $match[0];
|
||||
|
||||
// Prüfe ob eine der ignored classes enthalten ist
|
||||
$shouldIgnore = false;
|
||||
foreach ($ignoredClasses as $ignoredClass) {
|
||||
if (str_contains($matchContent, $ignoredClass)) {
|
||||
$shouldIgnore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldIgnore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $this->getLineNumber($content, $match[1]);
|
||||
$issues[] = [
|
||||
'type' => 'direct-class-usage',
|
||||
'message' => "Direct CSS class usage detected: '{$class}'. Use <x-{$component}> component instead.",
|
||||
'line' => $line,
|
||||
'suggestion' => "Replace with: <x-{$component} variant=\"...\">",
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft auf deprecated admin-* Klassen
|
||||
*
|
||||
* @param string $content Template-Inhalt
|
||||
* @return array<int, array{type: string, message: string, line: int, suggestion?: string}>
|
||||
*/
|
||||
private function checkDeprecatedClasses(string $content): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Pattern für admin-btn, admin-card, admin-badge
|
||||
$pattern = '/class=["\'][^"\']*\badmin-(btn|card|badge)/';
|
||||
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[1] as $index => $match) {
|
||||
$component = $match[0];
|
||||
$offset = $matches[0][$index][1];
|
||||
$line = $this->getLineNumber($content, $offset);
|
||||
|
||||
$issues[] = [
|
||||
'type' => 'deprecated-class',
|
||||
'message' => "Deprecated class detected: 'admin-{$component}'. Use <x-{$component}> component instead.",
|
||||
'line' => $line,
|
||||
'suggestion' => "Replace with: <x-{$component} variant=\"...\">",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft auf alte <component name="..."> Syntax
|
||||
*
|
||||
* @param string $content Template-Inhalt
|
||||
* @return array<int, array{type: string, message: string, line: int, suggestion?: string}>
|
||||
*/
|
||||
private function checkOldComponentSyntax(string $content): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Pattern für <component name="...">
|
||||
$pattern = '/<component\s+name=["\']([^"\']+)["\']/';
|
||||
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[1] as $index => $match) {
|
||||
$componentName = $match[0];
|
||||
$offset = $matches[0][$index][1];
|
||||
$line = $this->getLineNumber($content, $offset);
|
||||
|
||||
$issues[] = [
|
||||
'type' => 'old-syntax',
|
||||
'message' => "Old component syntax detected: <component name=\"{$componentName}\">. Use <x-{$componentName}> instead.",
|
||||
'line' => $line,
|
||||
'suggestion' => "Replace with: <x-{$componentName}>",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt Zeilennummer für einen Offset
|
||||
*
|
||||
* @param string $content Template-Inhalt
|
||||
* @param int $offset Byte-Offset
|
||||
* @return int Zeilennummer (1-basiert)
|
||||
*/
|
||||
private function getLineNumber(string $content, int $offset): int
|
||||
{
|
||||
return substr_count(substr($content, 0, $offset), "\n") + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lintet mehrere Template-Dateien
|
||||
*
|
||||
* @param array<string> $templatePaths Array von Template-Pfaden
|
||||
* @return array<string, array<int, array{type: string, message: string, line: int, suggestion?: string}>>
|
||||
*/
|
||||
public function lintMultiple(array $templatePaths): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($templatePaths as $templatePath) {
|
||||
$issues = $this->lint($templatePath);
|
||||
if (!empty($issues)) {
|
||||
$results[$templatePath] = $issues;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\View\Loading\TemplateLoader;
|
||||
use App\Framework\View\TemplateProcessor;
|
||||
use App\Framework\View\ValueObjects\DataAttributeHelper;
|
||||
use App\Framework\View\ValueObjects\LiveComponentCoreAttribute;
|
||||
|
||||
/**
|
||||
* Renders LiveComponent templates
|
||||
@@ -101,19 +103,19 @@ final readonly class LiveComponentRenderer
|
||||
|
||||
// Build attributes
|
||||
$attributes = [
|
||||
'data-live-component' => $componentId,
|
||||
'data-state' => $stateJson,
|
||||
'data-csrf-token' => $csrfToken->toString(),
|
||||
LiveComponentCoreAttribute::LIVE_COMPONENT->value() => $componentId,
|
||||
LiveComponentCoreAttribute::STATE->value() => $stateJson,
|
||||
LiveComponentCoreAttribute::CSRF_TOKEN->value() => $csrfToken->toString(),
|
||||
];
|
||||
|
||||
// Add SSE channel if provided
|
||||
if ($sseChannel !== null) {
|
||||
$attributes['data-sse-channel'] = $sseChannel;
|
||||
$attributes[LiveComponentCoreAttribute::SSE_CHANNEL->value()] = $sseChannel;
|
||||
}
|
||||
|
||||
// Add poll interval if provided
|
||||
if ($pollInterval !== null) {
|
||||
$attributes['data-poll-interval'] = (string) $pollInterval;
|
||||
$attributes[LiveComponentCoreAttribute::POLL_INTERVAL->value()] = (string) $pollInterval;
|
||||
}
|
||||
|
||||
// Build attribute string
|
||||
@@ -124,7 +126,7 @@ final readonly class LiveComponentRenderer
|
||||
function ($key, $value) {
|
||||
// For data-state, we use the JSON directly without additional escaping
|
||||
// The DOM parser will handle proper escaping when the HTML is parsed
|
||||
if ($key === 'data-state') {
|
||||
if ($key === DataAttributeHelper::toString(LiveComponentCoreAttribute::STATE)) {
|
||||
return sprintf('%s=\'%s\'', $key, $value);
|
||||
}
|
||||
// For other attributes, use standard escaping
|
||||
|
||||
@@ -21,8 +21,8 @@ final readonly class ComponentResolver implements TemplateResolverStrategy
|
||||
$this->templatePath . '/components/' . $template . '.html'
|
||||
);
|
||||
|
||||
if (file_exists($htmlPath)) {
|
||||
return $htmlPath;
|
||||
if (file_exists($htmlPath->toString())) {
|
||||
return $htmlPath->toString();
|
||||
}
|
||||
|
||||
// Fallback: .view.php Extension
|
||||
@@ -30,6 +30,6 @@ final readonly class ComponentResolver implements TemplateResolverStrategy
|
||||
$this->templatePath . '/components/' . $template . '.view.php'
|
||||
);
|
||||
|
||||
return file_exists($phpPath) ? $phpPath : null;
|
||||
return file_exists($phpPath->toString()) ? $phpPath->toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,31 @@ final readonly class ControllerResolver implements TemplateResolverStrategy
|
||||
return $templatesPath;
|
||||
}
|
||||
|
||||
// For Admin controllers, also try parent Admin/templates directory
|
||||
// This handles cases like App\Application\Admin\Database\DatabaseBrowserController
|
||||
// looking for templates in App\Application\Admin\templates\
|
||||
if (str_contains($controllerClass, '\\Admin\\')) {
|
||||
// Find the Admin directory by going up until we find Admin in the path
|
||||
$currentDir = $dir;
|
||||
$adminDir = null;
|
||||
|
||||
// Traverse up the directory tree to find Admin directory
|
||||
while ($currentDir !== dirname($currentDir)) { // Stop at root
|
||||
if (basename($currentDir) === 'Admin') {
|
||||
$adminDir = $currentDir;
|
||||
break;
|
||||
}
|
||||
$currentDir = dirname($currentDir);
|
||||
}
|
||||
|
||||
if ($adminDir !== null) {
|
||||
$adminTemplatesPath = $adminDir . DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . $template . self::TEMPLATE_EXTENSION;
|
||||
if (file_exists($adminTemplatesPath)) {
|
||||
return $adminTemplatesPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\ReflectionException) {
|
||||
return null;
|
||||
|
||||
@@ -22,6 +22,6 @@ final readonly class DefaultPathResolver implements TemplateResolverStrategy
|
||||
$this->templatePath . '/' . $template . self::TEMPLATE_EXTENSION
|
||||
);
|
||||
|
||||
return file_exists($path) ? $path : null;
|
||||
return file_exists($path->toString()) ? $path->toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@ final readonly class LayoutResolver implements TemplateResolverStrategy
|
||||
$this->templatePath . '/layouts/' . $template . self::TEMPLATE_EXTENSION
|
||||
);
|
||||
|
||||
return file_exists($path) ? $path : null;
|
||||
return file_exists($path->toString()) ? $path->toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
80
src/Framework/View/Processors/BooleanAttributeProcessor.php
Normal file
80
src/Framework/View/Processors/BooleanAttributeProcessor.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Processors;
|
||||
|
||||
use App\Framework\Template\Processing\StringProcessor;
|
||||
use App\Framework\View\RenderContext;
|
||||
|
||||
/**
|
||||
* BooleanAttributeProcessor
|
||||
*
|
||||
* Removes boolean HTML attributes when their value is "false".
|
||||
* HTML boolean attributes should be present (as flags) or absent, not set to "false".
|
||||
*
|
||||
* Handles: selected, checked, disabled, readonly, required, multiple, autofocus, etc.
|
||||
*/
|
||||
final readonly class BooleanAttributeProcessor implements StringProcessor
|
||||
{
|
||||
/**
|
||||
* List of boolean HTML attributes that should be removed when set to "false"
|
||||
*/
|
||||
private const BOOLEAN_ATTRIBUTES = [
|
||||
'selected',
|
||||
'checked',
|
||||
'disabled',
|
||||
'readonly',
|
||||
'required',
|
||||
'multiple',
|
||||
'autofocus',
|
||||
'autoplay',
|
||||
'controls',
|
||||
'loop',
|
||||
'muted',
|
||||
'open',
|
||||
'reversed',
|
||||
'scoped',
|
||||
'seamless',
|
||||
'async',
|
||||
'defer',
|
||||
'hidden',
|
||||
'ismap',
|
||||
'itemscope',
|
||||
'novalidate',
|
||||
'open',
|
||||
'pubdate',
|
||||
'spellcheck',
|
||||
'translate',
|
||||
];
|
||||
|
||||
public function process(string $html, RenderContext $context): string
|
||||
{
|
||||
// Process each boolean attribute
|
||||
foreach (self::BOOLEAN_ATTRIBUTES as $attr) {
|
||||
// Remove attribute when value is "false" (with quotes)
|
||||
$html = preg_replace(
|
||||
'/\s+' . preg_quote($attr, '/') . '="false"/i',
|
||||
'',
|
||||
$html
|
||||
);
|
||||
|
||||
// Remove attribute when value is 'false' (with single quotes)
|
||||
$html = preg_replace(
|
||||
'/\s+' . preg_quote($attr, '/') . '=\'false\'/i',
|
||||
'',
|
||||
$html
|
||||
);
|
||||
|
||||
// Remove attribute when value is false (without quotes - less common but possible)
|
||||
$html = preg_replace(
|
||||
'/\s+' . preg_quote($attr, '/') . '=false/i',
|
||||
'',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,8 @@ final readonly class ForStringProcessor implements StringProcessor
|
||||
// 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';
|
||||
// Supports nested paths like: $stack.containers as $container
|
||||
$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,
|
||||
@@ -249,8 +250,24 @@ final readonly class ForStringProcessor implements StringProcessor
|
||||
|
||||
$output = '';
|
||||
foreach ($data as $item) {
|
||||
// Replace loop variables in innerHTML
|
||||
$processedInnerHTML = $this->replaceForeachVariables($innerHTML, $varName, $item);
|
||||
// Create extended context with loop variable for nested foreach support
|
||||
$extendedContext = new RenderContext(
|
||||
template: $context->template,
|
||||
metaData: $context->metaData,
|
||||
data: array_merge($context->data, [$varName => $item]),
|
||||
controllerClass: $context->controllerClass
|
||||
);
|
||||
|
||||
// First replace placeholders that reference the loop variable (e.g., {{$stack.display_name}})
|
||||
// This must happen BEFORE processing nested foreach, so the loop variable is available
|
||||
$placeholderReplacer = $this->container->get(\App\Framework\View\Processors\PlaceholderReplacer::class);
|
||||
$processedInnerHTML = $placeholderReplacer->process($innerHTML, $extendedContext);
|
||||
|
||||
// Then process nested foreach attributes (they may contain placeholders for nested loop variables)
|
||||
$processedInnerHTML = $this->processForeachAttributes($processedInnerHTML, $extendedContext);
|
||||
|
||||
// Finally, replace any remaining placeholders (including nested loop variables like $container)
|
||||
$processedInnerHTML = $placeholderReplacer->process($processedInnerHTML, $extendedContext);
|
||||
|
||||
// Reconstruct the element
|
||||
$output .= "<{$tagName}" . ($allAttrs ? " {$allAttrs}" : '') . ">{$processedInnerHTML}</{$tagName}>";
|
||||
|
||||
@@ -52,8 +52,8 @@ final readonly class FormProcessor implements DomProcessor
|
||||
foreach ($forms as $form) {
|
||||
$formId = $this->formIdGenerator->generateFromRenderContext($form, $context);
|
||||
|
||||
// 1. Add CSRF token placeholder
|
||||
$this->addCsrfTokenPlaceholder($dom, $form);
|
||||
// 1. Add CSRF token placeholder (with formId for FormDataResponseProcessor)
|
||||
$this->addCsrfTokenPlaceholder($dom, $form, $formId);
|
||||
|
||||
// 2. Add form ID
|
||||
$this->addFormId($dom, $form, $formId);
|
||||
@@ -68,12 +68,15 @@ final readonly class FormProcessor implements DomProcessor
|
||||
return $dom;
|
||||
}
|
||||
|
||||
private function addCsrfTokenPlaceholder(DomWrapper $dom, Element $form): void
|
||||
private function addCsrfTokenPlaceholder(DomWrapper $dom, Element $form, FormId $formId): void
|
||||
{
|
||||
$formIdString = (string) $formId;
|
||||
$placeholder = "___TOKEN_{$formIdString}___";
|
||||
|
||||
// Check if CSRF token already exists
|
||||
$existing = $form->querySelector('input[name="_token"]');
|
||||
if ($existing) {
|
||||
$existing->setAttribute('value', '___TOKEN___');
|
||||
$existing->setAttribute('value', $placeholder);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -81,7 +84,7 @@ final readonly class FormProcessor implements DomProcessor
|
||||
$csrf = $dom->document->createElement('input');
|
||||
$csrf->setAttribute('name', '_token');
|
||||
$csrf->setAttribute('type', 'hidden');
|
||||
$csrf->setAttribute('value', '___TOKEN___');
|
||||
$csrf->setAttribute('value', $placeholder);
|
||||
|
||||
$form->insertBefore($csrf, $form->firstChild);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,31 @@ final readonly class IncludeProcessor implements DomProcessor
|
||||
|
||||
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
|
||||
{
|
||||
foreach ($dom->querySelectorAll('include[file]') as $includeNode) {
|
||||
$file = $includeNode->getAttribute('file');
|
||||
// Support both 'file' and 'template' attributes
|
||||
foreach ($dom->querySelectorAll('include[file], include[template]') as $includeNode) {
|
||||
$file = $includeNode->getAttribute('file') ?? $includeNode->getAttribute('template');
|
||||
|
||||
// Merge data attribute into context if provided
|
||||
$dataAttribute = $includeNode->getAttribute('data');
|
||||
$mergedData = $context->data;
|
||||
|
||||
if ($dataAttribute !== null) {
|
||||
// Parse data attribute (e.g., "{pagination: $pagination, items_label: 'rows'}")
|
||||
$parsedData = $this->parseDataAttribute($dataAttribute, $context->data);
|
||||
$mergedData = array_merge($context->data, $parsedData);
|
||||
}
|
||||
|
||||
try {
|
||||
$html = $this->loader->load($file);
|
||||
$includedDom = $this->parser->parseFile($html);
|
||||
|
||||
// Create new context with merged data
|
||||
$includeContext = new RenderContext(
|
||||
template: $file,
|
||||
metaData: $context->metaData,
|
||||
data: $mergedData,
|
||||
isPartial: true
|
||||
);
|
||||
|
||||
$fragment = $dom->createDocumentFragment();
|
||||
foreach ($includedDom->documentElement->childNodes as $child) {
|
||||
@@ -42,4 +61,69 @@ final readonly class IncludeProcessor implements DomProcessor
|
||||
|
||||
return $dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse data attribute string into array
|
||||
* Supports: {key: value, key2: $variable}
|
||||
*/
|
||||
private function parseDataAttribute(string $dataString, array $contextData): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
// Remove outer braces if present
|
||||
$dataString = trim($dataString);
|
||||
if (str_starts_with($dataString, '{') && str_ends_with($dataString, '}')) {
|
||||
$dataString = substr($dataString, 1, -1);
|
||||
}
|
||||
|
||||
// Simple parsing: split by comma and parse key:value pairs
|
||||
$pairs = explode(',', $dataString);
|
||||
foreach ($pairs as $pair) {
|
||||
$pair = trim($pair);
|
||||
if (empty($pair)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($pair, ':')) {
|
||||
[$key, $value] = explode(':', $pair, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
|
||||
// Remove quotes if present
|
||||
if ((str_starts_with($value, '"') && str_ends_with($value, '"')) ||
|
||||
(str_starts_with($value, "'") && str_ends_with($value, "'"))) {
|
||||
$value = substr($value, 1, -1);
|
||||
} elseif (str_starts_with($value, '$')) {
|
||||
// Variable reference
|
||||
$varName = substr($value, 1);
|
||||
$value = $this->resolveValue($contextData, $varName);
|
||||
}
|
||||
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve value from context data using dot notation or array access
|
||||
*/
|
||||
private function resolveValue(array $data, string $key): mixed
|
||||
{
|
||||
if (str_contains($key, '.')) {
|
||||
$parts = explode('.', $key);
|
||||
$value = $data;
|
||||
foreach ($parts as $part) {
|
||||
if (is_array($value) && array_key_exists($part, $value)) {
|
||||
$value = $value[$part];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $data[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ 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\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;
|
||||
@@ -34,7 +36,7 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
*/
|
||||
private array $allowedTemplateFunctions = [
|
||||
'date', 'format_date', 'format_currency', 'format_filesize',
|
||||
'strtoupper', 'strtolower', 'ucfirst', 'trim', 'count', /*'imageslot'*/
|
||||
'strtoupper', 'strtolower', 'ucfirst', 'trim', 'count', 'number_format', /*'assetslot'*/
|
||||
];
|
||||
|
||||
public function process(string $html, RenderContext $context): string
|
||||
@@ -56,12 +58,13 @@ 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"] }}
|
||||
// Also supports array bracket syntax: {{ $model['key'] }} or {{ $model["key"] }} or {{ $model[0] }}
|
||||
return preg_replace_callback(
|
||||
'/{{\\s*\\$?([\\w.\\[\\]\'\"]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
|
||||
'/{{\\s*\\$?([\\w.\\[\\]\\d\'"]+)(?:\\(\\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);
|
||||
@@ -81,7 +84,7 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
$functionName = $matches[1];
|
||||
$params = trim($matches[2]);
|
||||
|
||||
$functions = new TemplateFunctions($this->container, ImageSlotFunction::class, LazyComponentFunction::class, UrlFunction::class);
|
||||
$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, $context->data);
|
||||
@@ -208,6 +211,11 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
// Table-Objekte - direkt rendern
|
||||
if ($value instanceof Table) {
|
||||
return $value->render();
|
||||
}
|
||||
|
||||
// Strings direkt ausgeben (KEIN escaping!)
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
@@ -229,8 +237,9 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
{
|
||||
$value = $this->resolveValue($data, $expr);
|
||||
if ($value === null) {
|
||||
// Bleibt als Platzhalter stehen
|
||||
return '{{ ' . $expr . ' }}';
|
||||
// Return empty string if value not found (placeholder cannot be resolved)
|
||||
// PlaceholderTransformer will handle loop variables from scopes
|
||||
return '';
|
||||
}
|
||||
|
||||
// LiveComponentContract - automatisch rendern mit Wrapper
|
||||
@@ -251,6 +260,11 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
// Table-Objekte - direkt rendern (HTML ist bereits escaped in render())
|
||||
if ($value instanceof Table) {
|
||||
return $value->render();
|
||||
}
|
||||
|
||||
// Arrays und komplexe Objekte können nicht direkt als String dargestellt werden
|
||||
if (is_array($value)) {
|
||||
return $this->handleArrayValue($expr, $value);
|
||||
@@ -278,18 +292,24 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
|
||||
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']
|
||||
// Remove leading $ if present (for loop variables like $stack, $container)
|
||||
// This allows both {{$stack.display_name}} and {{stack.display_name}} to work
|
||||
$originalExpr = $expr;
|
||||
$expr = ltrim($expr, '$');
|
||||
|
||||
// Handle array bracket syntax: $var['key'] or $var["key"] or $var[0]
|
||||
// Can be chained: $var['key1']['key2'] or mixed: $var.prop['key'] or $var[0][1]
|
||||
$value = $data;
|
||||
|
||||
// Split expression into parts, handling both dot notation and bracket notation
|
||||
$pattern = '/([\\w]+)|\\[([\'"])([^\\2]+?)\\2\\]/';
|
||||
// Pattern matches: word characters OR ['key'] OR ["key"] OR [0] (numeric index)
|
||||
$pattern = '/([\\w]+)|\\[([\'"])([^\\2]+?)\\2\\]|\\[(\\d+)\\]/';
|
||||
preg_match_all($pattern, $expr, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
// Check each match type - they are mutually exclusive per match
|
||||
if (!empty($match[1])) {
|
||||
// Dot notation: variable.property
|
||||
// Word characters: variable name or property
|
||||
$key = $match[1];
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
@@ -299,13 +319,22 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
return null;
|
||||
}
|
||||
} elseif (!empty($match[3])) {
|
||||
// Bracket notation: variable['key'] or variable["key"]
|
||||
// Bracket notation with string key: variable['key'] or variable["key"]
|
||||
$key = $match[3];
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} elseif (isset($match[4]) && $match[4] !== '') {
|
||||
// Bracket notation with numeric index: variable[0]
|
||||
// Use isset() instead of !empty() because 0 is a valid index
|
||||
$index = (int) $match[4];
|
||||
if (is_array($value) && array_key_exists($index, $value)) {
|
||||
$value = $value[$index];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Framework\Template\Processing\DomProcessor;
|
||||
use App\Framework\View\DomComponentService;
|
||||
use App\Framework\View\DomWrapper;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\View\ValueObjects\LiveComponentLazyAttribute;
|
||||
use Dom\HTMLElement;
|
||||
|
||||
/**
|
||||
@@ -117,17 +118,106 @@ final readonly class XComponentProcessor implements DomProcessor
|
||||
|
||||
// Check if it's a LiveComponent (has state) or StaticComponent
|
||||
$metadata = $this->metadataCache->get($className);
|
||||
$isLiveComponent = isset($metadata->state);
|
||||
$isLiveComponent = isset($metadata->properties['state']);
|
||||
|
||||
if ($isLiveComponent) {
|
||||
// Process as LiveComponent (Interactive/Stateful)
|
||||
$this->processAsLiveComponent($dom, $element, $componentName);
|
||||
// Check if component is an Island
|
||||
if ($metadata->isIsland()) {
|
||||
// Process as Island Component (isolated rendering)
|
||||
$this->processAsIslandComponent($dom, $element, $componentName, $metadata);
|
||||
} else {
|
||||
// Process as LiveComponent (Interactive/Stateful)
|
||||
$this->processAsLiveComponent($dom, $element, $componentName);
|
||||
}
|
||||
} else {
|
||||
// Process as StaticComponent (Server-rendered)
|
||||
$this->processAsStaticComponent($dom, $element, $componentName);
|
||||
$this->processAsStaticComponent($dom, $element, $componentName, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process as Island Component (isolated rendering)
|
||||
*/
|
||||
private function processAsIslandComponent(
|
||||
DomWrapper $dom,
|
||||
HTMLElement $element,
|
||||
string $componentName,
|
||||
\App\Framework\LiveComponents\Performance\CompiledComponentMetadata $metadata
|
||||
): void {
|
||||
// Extract props from HTML attributes
|
||||
$props = $this->extractProps($element);
|
||||
|
||||
// Validate props against ComponentMetadata
|
||||
$this->validateLiveComponentProps($componentName, $props);
|
||||
|
||||
// Create ComponentId (use 'id' prop or generate unique ID)
|
||||
$instanceId = isset($props['id']) && is_string($props['id'])
|
||||
? $props['id']
|
||||
: $this->generateInstanceId($componentName);
|
||||
unset($props['id']); // Remove 'id' from props as it's used for ComponentId
|
||||
|
||||
$componentId = ComponentId::create($componentName, $instanceId);
|
||||
|
||||
// Get Island configuration
|
||||
$island = $metadata->getIsland();
|
||||
assert($island !== null, 'Island metadata should be present');
|
||||
|
||||
if ($island['lazy']) {
|
||||
// Generate lazy-loading placeholder for Island
|
||||
$placeholderHtml = $this->generateIslandPlaceholder($componentId, $island);
|
||||
$this->componentService->replaceComponent($dom, $element, $placeholderHtml);
|
||||
} else {
|
||||
// Render island immediately but isolated (will be handled by endpoint)
|
||||
// For now, generate placeholder that will be loaded via endpoint
|
||||
$placeholderHtml = $this->generateIslandPlaceholder($componentId, $island, lazy: false);
|
||||
$this->componentService->replaceComponent($dom, $element, $placeholderHtml);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Island placeholder HTML
|
||||
*/
|
||||
private function generateIslandPlaceholder(
|
||||
ComponentId $componentId,
|
||||
array $island,
|
||||
bool $lazy = true
|
||||
): string {
|
||||
$componentIdString = $componentId->toString();
|
||||
$placeholder = $island['placeholder'] ?? 'Loading...';
|
||||
|
||||
// Build attributes array
|
||||
$attributes = [
|
||||
LiveComponentLazyAttribute::ISLAND_COMPONENT->value() => 'true',
|
||||
];
|
||||
|
||||
if ($lazy) {
|
||||
$attributes[LiveComponentLazyAttribute::LIVE_COMPONENT_LAZY->value()] = htmlspecialchars($componentIdString, ENT_QUOTES | ENT_HTML5);
|
||||
$attributes[LiveComponentLazyAttribute::LAZY_PRIORITY->value()] = 'normal';
|
||||
$attributes[LiveComponentLazyAttribute::LAZY_THRESHOLD->value()] = '0.1';
|
||||
} else {
|
||||
// Non-lazy island: load immediately via endpoint
|
||||
$attributes[LiveComponentLazyAttribute::LIVE_COMPONENT_ISLAND->value()] = htmlspecialchars($componentIdString, ENT_QUOTES | ENT_HTML5);
|
||||
}
|
||||
|
||||
if ($placeholder !== null) {
|
||||
$attributes[LiveComponentLazyAttribute::LAZY_PLACEHOLDER->value()] = htmlspecialchars($placeholder, ENT_QUOTES | ENT_HTML5);
|
||||
}
|
||||
|
||||
// Convert attributes to HTML string
|
||||
$attributesHtml = implode(' ', array_map(
|
||||
fn ($key, $value) => sprintf('%s="%s"', $key, $value),
|
||||
array_keys($attributes),
|
||||
$attributes
|
||||
));
|
||||
|
||||
// Generate placeholder HTML
|
||||
return sprintf(
|
||||
'<div %s><div class="island-placeholder">%s</div></div>',
|
||||
$attributesHtml,
|
||||
htmlspecialchars($placeholder, ENT_QUOTES | ENT_HTML5)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process as LiveComponent (Interactive)
|
||||
*/
|
||||
@@ -169,23 +259,57 @@ final readonly class XComponentProcessor implements DomProcessor
|
||||
private function processAsStaticComponent(
|
||||
DomWrapper $dom,
|
||||
HTMLElement $element,
|
||||
string $componentName
|
||||
string $componentName,
|
||||
RenderContext $context
|
||||
): void {
|
||||
// Get element content and attributes
|
||||
$content = $element->textContent ?? '';
|
||||
// Get element content as HTML (preserving HTML structure)
|
||||
// Use innerHTML property if available, otherwise extract manually
|
||||
$content = '';
|
||||
if (property_exists($element, 'innerHTML')) {
|
||||
$content = $element->innerHTML ?? '';
|
||||
} else {
|
||||
// Fallback: manually extract HTML from child nodes
|
||||
$content = $this->extractInnerHtml($element);
|
||||
}
|
||||
|
||||
$attributes = $this->extractAttributesAsArray($element);
|
||||
|
||||
// Process placeholders in attributes (like ComponentProcessor does)
|
||||
$processedAttributes = $this->processAttributeContent($attributes, $context);
|
||||
|
||||
// Render via ComponentRegistry.renderStatic()
|
||||
$rendered = $this->componentRegistry->renderStatic(
|
||||
$componentName,
|
||||
$content,
|
||||
$attributes
|
||||
$processedAttributes
|
||||
);
|
||||
|
||||
// Replace element with rendered HTML
|
||||
$this->componentService->replaceComponent($dom, $element, $rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inner HTML from element (preserving HTML tags and structure)
|
||||
*/
|
||||
private function extractInnerHtml(HTMLElement $element): string
|
||||
{
|
||||
$html = '';
|
||||
$childNodes = $element->childNodes;
|
||||
if ($childNodes !== null) {
|
||||
foreach ($childNodes as $child) {
|
||||
if ($child instanceof \DOMElement) {
|
||||
$html .= $element->ownerDocument->saveHTML($child);
|
||||
} elseif ($child instanceof \DOMText) {
|
||||
// Only escape text nodes, not HTML elements
|
||||
$html .= htmlspecialchars($child->textContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
} elseif ($child instanceof \DOMCDataSection) {
|
||||
$html .= $child->textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract component name from tag: <x-datatable> → "datatable"
|
||||
*/
|
||||
@@ -211,9 +335,19 @@ final readonly class XComponentProcessor implements DomProcessor
|
||||
{
|
||||
$props = [];
|
||||
|
||||
foreach ($element->attributes as $attr) {
|
||||
$name = $attr->nodeName;
|
||||
$value = $attr->nodeValue ?? '';
|
||||
foreach ($element->getAttributes() as $attr) {
|
||||
// Handle both Dom\Attr (from Dom library) and HtmlAttribute (from our framework)
|
||||
if (is_object($attr) && property_exists($attr, 'nodeName')) {
|
||||
$name = $attr->nodeName;
|
||||
$value = $attr->nodeValue ?? '';
|
||||
} elseif ($attr instanceof \App\Framework\View\ValueObjects\HtmlAttribute) {
|
||||
$name = $attr->name();
|
||||
$value = $attr->value() ?? '';
|
||||
} else {
|
||||
// Fallback: try to access as array or object
|
||||
$name = is_array($attr) ? ($attr['name'] ?? '') : ($attr->name ?? '');
|
||||
$value = is_array($attr) ? ($attr['value'] ?? '') : ($attr->value ?? '');
|
||||
}
|
||||
|
||||
// Type coercion: "123" → 123, "true" → true, "[1,2]" → [1,2]
|
||||
$props[$name] = $this->coerceType($value);
|
||||
@@ -231,13 +365,114 @@ final readonly class XComponentProcessor implements DomProcessor
|
||||
{
|
||||
$attributes = [];
|
||||
|
||||
foreach ($element->attributes as $attr) {
|
||||
$attributes[$attr->nodeName] = $attr->nodeValue ?? '';
|
||||
foreach ($element->getAttributes() as $attr) {
|
||||
// Handle both Dom\Attr (from Dom library) and HtmlAttribute (from our framework)
|
||||
if (is_object($attr) && property_exists($attr, 'nodeName')) {
|
||||
$attributes[$attr->nodeName] = $attr->nodeValue ?? '';
|
||||
} elseif ($attr instanceof \App\Framework\View\ValueObjects\HtmlAttribute) {
|
||||
$attributes[$attr->name()] = $attr->value() ?? '';
|
||||
} else {
|
||||
// Fallback: try to access as array or object
|
||||
$name = is_array($attr) ? ($attr['name'] ?? '') : ($attr->name ?? '');
|
||||
$value = is_array($attr) ? ($attr['value'] ?? '') : ($attr->value ?? '');
|
||||
if ($name !== '') {
|
||||
$attributes[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process attribute content for placeholders before passing to component renderer
|
||||
* Note: Placeholders should already be processed by ForTransformer/PlaceholderTransformer
|
||||
* This is just a fallback for any remaining placeholders
|
||||
*/
|
||||
private function processAttributeContent(array $attributes, RenderContext $context): array
|
||||
{
|
||||
$processedAttributes = [];
|
||||
$allVariables = $context->getAllVariables();
|
||||
|
||||
foreach ($attributes as $key => $value) {
|
||||
// Process placeholders in attribute values (fallback - should already be processed)
|
||||
if (is_string($value) && str_contains($value, '{{')) {
|
||||
// For href attributes, use raw processing to avoid double-escaping
|
||||
if ($key === 'href') {
|
||||
$processedAttributes[$key] = $this->processPlaceholdersInAttributeRaw($value, $allVariables);
|
||||
} else {
|
||||
$processedAttributes[$key] = $this->processPlaceholdersInAttribute($value, $allVariables);
|
||||
}
|
||||
} else {
|
||||
$processedAttributes[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $processedAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in a single attribute value
|
||||
*/
|
||||
private function processPlaceholdersInAttribute(string $value, array $variables): string
|
||||
{
|
||||
// Use ExpressionEvaluator to evaluate placeholders
|
||||
$evaluator = new \App\Framework\Template\Expression\ExpressionEvaluator();
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{\s*([^}]+?)\s*}}/',
|
||||
function ($matches) use ($evaluator, $variables) {
|
||||
$expression = trim($matches[1]);
|
||||
try {
|
||||
$result = $evaluator->evaluate($expression, $variables);
|
||||
// Convert result to string for attribute value
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return '';
|
||||
}
|
||||
return (string) $result;
|
||||
} catch (\Throwable $e) {
|
||||
// If evaluation fails, return original placeholder
|
||||
return $matches[0];
|
||||
}
|
||||
},
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process placeholders in href attribute values without escaping (URLs)
|
||||
*/
|
||||
private function processPlaceholdersInAttributeRaw(string $value, array $variables): string
|
||||
{
|
||||
// Use ExpressionEvaluator to evaluate placeholders
|
||||
$evaluator = new \App\Framework\Template\Expression\ExpressionEvaluator();
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{\s*([^}]+?)\s*}}/',
|
||||
function ($matches) use ($evaluator, $variables) {
|
||||
$expression = trim($matches[1]);
|
||||
try {
|
||||
$result = $evaluator->evaluate($expression, $variables);
|
||||
// Convert result to string for URL (no escaping - will be escaped by setAttribute)
|
||||
if (is_bool($result)) {
|
||||
return $result ? 'true' : 'false';
|
||||
}
|
||||
if ($result === null) {
|
||||
return '';
|
||||
}
|
||||
return (string) $result;
|
||||
} catch (\Throwable $e) {
|
||||
// If evaluation fails, return original placeholder
|
||||
return $matches[0];
|
||||
}
|
||||
},
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type coercion for prop values (LiveComponents only)
|
||||
*
|
||||
|
||||
@@ -6,9 +6,12 @@ namespace App\Framework\View;
|
||||
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Template\TemplateContext;
|
||||
use App\Framework\View\ValueObjects\ScopeStack;
|
||||
|
||||
final readonly class RenderContext implements TemplateContext
|
||||
{
|
||||
public readonly ScopeStack $scopes;
|
||||
|
||||
public function __construct(
|
||||
public string $template, // Dateiname oder Template-Key
|
||||
public MetaData $metaData,
|
||||
@@ -19,6 +22,81 @@ final readonly class RenderContext implements TemplateContext
|
||||
public ?string $route = null, // Route name for form ID generation
|
||||
public ProcessingMode $processingMode = ProcessingMode::FULL,
|
||||
public bool $isPartial = false, // Deprecated: Use processingMode instead
|
||||
?ScopeStack $scopes = null, // Stack von Scopes für verschachtelte Loops
|
||||
) {
|
||||
// Initialize empty ScopeStack if not provided
|
||||
$this->scopes = $scopes ?? ScopeStack::empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen RenderContext mit zusätzlichem Scope
|
||||
*
|
||||
* Der neue Scope wird am Anfang des Scope-Stacks eingefügt (höchste Priorität).
|
||||
* Dies ermöglicht verschachtelte Loops, bei denen innere Loop-Variablen
|
||||
* äußere Variablen überschreiben können.
|
||||
*
|
||||
* @param array<string, mixed>|\App\Framework\View\ValueObjects\TemplateScope $variables
|
||||
* Variablen für den neuen Scope (z.B. ['item' => $item]) oder TemplateScope
|
||||
* @return self Neuer RenderContext mit erweitertem Scope-Stack
|
||||
*/
|
||||
public function withScope(array|\App\Framework\View\ValueObjects\TemplateScope $variables): self
|
||||
{
|
||||
return new self(
|
||||
template: $this->template,
|
||||
metaData: $this->metaData,
|
||||
data: $this->data,
|
||||
layout: $this->layout,
|
||||
slots: $this->slots,
|
||||
controllerClass: $this->controllerClass,
|
||||
route: $this->route,
|
||||
processingMode: $this->processingMode,
|
||||
isPartial: $this->isPartial,
|
||||
scopes: $this->scopes->push($variables),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst eine Variable aus allen verfügbaren Scopes auf
|
||||
*
|
||||
* Sucht die Variable zuerst in den Scopes (von innen nach außen),
|
||||
* dann in den globalen Daten.
|
||||
*
|
||||
* @param string $name Variablenname (ohne $)
|
||||
* @return mixed Wert der Variable oder null wenn nicht gefunden
|
||||
*/
|
||||
public function resolveVariable(string $name): mixed
|
||||
{
|
||||
// Suche in Scopes (von innen nach außen)
|
||||
$value = $this->scopes->resolve($name);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Fallback zu globalen Daten
|
||||
if (array_key_exists($name, $this->data)) {
|
||||
return $this->data[$name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle verfügbaren Variablen zurück (alle Scopes + globale Daten)
|
||||
*
|
||||
* Die Variablen werden gemerged, wobei innerste Scopes Priorität haben.
|
||||
* Dies ist nützlich für ExpressionEvaluator, der einen flachen Array erwartet.
|
||||
*
|
||||
* @return array<string, mixed> Alle Variablen mit Scope-Priorität (innerste Scope überschreibt äußere)
|
||||
*/
|
||||
public function getAllVariables(): array
|
||||
{
|
||||
// Beginne mit globalen Daten
|
||||
$allVariables = $this->data;
|
||||
|
||||
// Merge Scopes (ScopeStack handles priority: inner scopes override outer)
|
||||
$scopeVariables = $this->scopes->getAllVariables();
|
||||
$allVariables = array_merge($allVariables, $scopeVariables);
|
||||
|
||||
return $allVariables;
|
||||
}
|
||||
}
|
||||
|
||||
390
src/Framework/View/Response/FormDataResponseProcessor.php
Normal file
390
src/Framework/View/Response/FormDataResponseProcessor.php
Normal file
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Response;
|
||||
|
||||
use App\Framework\Http\Session\FormIdGenerator;
|
||||
use App\Framework\Http\Session\SessionInterface;
|
||||
use App\Framework\Http\Session\SessionManager;
|
||||
|
||||
/**
|
||||
* FormDataResponseProcessor - Processes HTML to replace form-related placeholders
|
||||
* with actual session data (CSRF tokens, old input values, validation errors).
|
||||
*
|
||||
* This processor works in conjunction with FormTransformer, which adds placeholders
|
||||
* during template compilation. This processor replaces them at response time.
|
||||
*
|
||||
* Placeholder format: ___TOKEN_FORMID___, ___OLD_INPUT_FORMID_FIELDNAME___, etc.
|
||||
* This ensures each placeholder is uniquely associated with a specific form.
|
||||
*/
|
||||
final class FormDataResponseProcessor
|
||||
{
|
||||
/**
|
||||
* Cache für bereits generierte Token pro Request
|
||||
* Verhindert mehrfache Token-Generierung für dieselbe Form-ID
|
||||
* @var array<string, \App\Framework\Security\CsrfToken>
|
||||
*/
|
||||
private array $tokenCache = [];
|
||||
|
||||
public function __construct(
|
||||
private FormIdGenerator $formIdGenerator,
|
||||
private SessionManager $sessionManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process HTML content and replace form placeholders with actual values
|
||||
*/
|
||||
public function process(string $html, SessionInterface $session, ?array &$tokenCache = null): string
|
||||
{
|
||||
$formIds = $this->formIdGenerator->extractFormIdsFromHtml($html);
|
||||
|
||||
error_log("FormDataResponseProcessor: Found " . count($formIds) . " form ID(s): " . implode(', ', $formIds));
|
||||
|
||||
if (empty($formIds)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Entferne Duplikate (falls vorhanden)
|
||||
$formIds = array_unique($formIds);
|
||||
|
||||
// Process each form
|
||||
foreach ($formIds as $formId) {
|
||||
// 1. Replace CSRF tokens using simple string replacement
|
||||
$html = $this->replaceTokenForForm($html, $formId, $session);
|
||||
|
||||
// 2. Replace honeypot data
|
||||
$html = $this->replaceHoneypotData($html, $formId, $session);
|
||||
|
||||
// 3. Replace old input values
|
||||
$html = $this->replaceOldInputForForm($html, $formId, $session);
|
||||
|
||||
// 4. Replace error messages
|
||||
$html = $this->replaceErrorsForForm($html, $formId, $session);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HTML contains any form-related placeholders
|
||||
*/
|
||||
public function hasPlaceholders(string $html): bool
|
||||
{
|
||||
$hasToken = preg_match('/___TOKEN_[a-z0-9_]+___/', $html);
|
||||
$hasOldInput = preg_match('/___OLD_(INPUT|SELECT|RADIO|CHECKBOX)_[a-z0-9_]+___/', $html);
|
||||
$hasError = preg_match('/___ERROR_[a-z0-9_]+___/', $html);
|
||||
|
||||
return $hasToken || $hasOldInput || $hasError;
|
||||
}
|
||||
|
||||
private function replaceOldInputForForm(string $html, string $formId, SessionInterface $session): string
|
||||
{
|
||||
// Use session's form storage property
|
||||
if (! $session->form->has($formId)) {
|
||||
// No old data, remove placeholders for this form
|
||||
return $this->cleanupOldInputPlaceholders($html, $formId);
|
||||
}
|
||||
|
||||
$oldData = $session->form->getAndFlash($formId);
|
||||
|
||||
foreach ($oldData as $fieldName => $value) {
|
||||
// Text inputs and textareas - use form-ID-specific placeholder
|
||||
$placeholder = "___OLD_INPUT_{$formId}_{$fieldName}___";
|
||||
if (str_contains($html, $placeholder)) {
|
||||
$escapedValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
// Replace in value attributes
|
||||
$html = preg_replace(
|
||||
'/value=(["\'])___OLD_INPUT_' . preg_quote($formId, '/') . '_' . preg_quote($fieldName, '/') . '___\1/',
|
||||
'value=$1' . $escapedValue . '$1',
|
||||
$html
|
||||
);
|
||||
// Replace in textarea content
|
||||
$html = preg_replace(
|
||||
'/>___OLD_INPUT_' . preg_quote($formId, '/') . '_' . preg_quote($fieldName, '/') . '___</',
|
||||
'>' . $escapedValue . '<',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
// Select options - use form-ID-specific placeholder
|
||||
if (is_scalar($value)) {
|
||||
$selectPattern = "___OLD_SELECT_{$formId}_{$fieldName}_{$value}___";
|
||||
$html = str_replace(
|
||||
"data-selected-if=\"{$selectPattern}\"",
|
||||
'selected="selected"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
// Radio buttons - use form-ID-specific placeholder
|
||||
if (is_scalar($value)) {
|
||||
$radioPattern = "___OLD_RADIO_{$formId}_{$fieldName}_{$value}___";
|
||||
$html = str_replace(
|
||||
"data-checked-if=\"{$radioPattern}\"",
|
||||
'checked="checked"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
// Checkboxes - use form-ID-specific placeholder
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $checkboxValue) {
|
||||
$checkboxPattern = "___OLD_CHECKBOX_{$formId}_{$fieldName}_{$checkboxValue}___";
|
||||
$html = str_replace(
|
||||
"data-checked-if=\"{$checkboxPattern}\"",
|
||||
'checked="checked"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
} elseif ($value) {
|
||||
$checkboxValue = is_bool($value) ? '1' : (string) $value;
|
||||
$checkboxPattern = "___OLD_CHECKBOX_{$formId}_{$fieldName}_{$checkboxValue}___";
|
||||
$html = str_replace(
|
||||
"data-checked-if=\"{$checkboxPattern}\"",
|
||||
'checked="checked"',
|
||||
$html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->cleanupOldInputPlaceholders($html, $formId);
|
||||
}
|
||||
|
||||
private function replaceErrorsForForm(string $html, string $formId, SessionInterface $session): string
|
||||
{
|
||||
// Use session's validation property
|
||||
if (! $session->validation->has($formId)) {
|
||||
// No errors, remove error placeholders for this form
|
||||
return $this->cleanupErrorPlaceholders($html, $formId);
|
||||
}
|
||||
|
||||
$errors = $session->validation->getAndFlash($formId);
|
||||
|
||||
foreach ($errors as $fieldName => $fieldErrors) {
|
||||
// Use form-ID-specific placeholder
|
||||
$placeholder = "___ERROR_{$formId}_{$fieldName}___";
|
||||
|
||||
if (! str_contains($html, $placeholder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($fieldErrors)) {
|
||||
// Remove error display
|
||||
$html = $this->removeErrorDisplay($html, $fieldName);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build error HTML
|
||||
$errorHtml = '';
|
||||
foreach ($fieldErrors as $error) {
|
||||
$escapedError = htmlspecialchars($error, ENT_QUOTES, 'UTF-8');
|
||||
$errorHtml .= "<span class=\"error-message\">{$escapedError}</span>";
|
||||
}
|
||||
|
||||
// Replace placeholder with actual errors
|
||||
$html = str_replace($placeholder, $errorHtml, $html);
|
||||
|
||||
// Add error class to the field
|
||||
$html = $this->addErrorClassToField($html, $fieldName);
|
||||
}
|
||||
|
||||
return $this->cleanupErrorPlaceholders($html, $formId);
|
||||
}
|
||||
|
||||
private function cleanupOldInputPlaceholders(string $html, string $formId): string
|
||||
{
|
||||
// Remove remaining old input placeholders for this form
|
||||
$html = preg_replace('/___OLD_INPUT_' . preg_quote($formId, '/') . '_[^_]+___/', '', $html);
|
||||
$html = preg_replace('/___OLD_SELECT_' . preg_quote($formId, '/') . '_[^_]+___/', '', $html);
|
||||
$html = preg_replace('/___OLD_RADIO_' . preg_quote($formId, '/') . '_[^_]+___/', '', $html);
|
||||
$html = preg_replace('/___OLD_CHECKBOX_' . preg_quote($formId, '/') . '_[^_]+___/', '', $html);
|
||||
|
||||
// Remove remaining data attributes for this form
|
||||
$html = preg_replace('/\s*data-selected-if="___OLD_SELECT_' . preg_quote($formId, '/') . '_[^"]*"/', '', $html);
|
||||
$html = preg_replace('/\s*data-checked-if="___OLD_(RADIO|CHECKBOX)_' . preg_quote($formId, '/') . '_[^"]*"/', '', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function cleanupErrorPlaceholders(string $html, string $formId): string
|
||||
{
|
||||
// Remove empty error displays for this form
|
||||
$html = preg_replace('/<div[^>]*class="[^"]*error-display[^"]*"[^>]*>___ERROR_' . preg_quote($formId, '/') . '_[^_]+___<\/div>/', '', $html);
|
||||
$html = preg_replace('/___ERROR_' . preg_quote($formId, '/') . '_[^_]+___/', '', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function removeErrorDisplay(string $html, string $fieldName): string
|
||||
{
|
||||
$pattern = '/<div[^>]*class="[^"]*error-display[^"]*"[^>]*data-field="' . preg_quote($fieldName, '/') . '"[^>]*>.*?<\/div>/s';
|
||||
|
||||
return preg_replace($pattern, '', $html);
|
||||
}
|
||||
|
||||
private function addErrorClassToField(string $html, string $fieldName): string
|
||||
{
|
||||
// Add 'error' class to field - handle existing class attribute
|
||||
$pattern = '/(<(?:input|select|textarea)[^>]*name="' . preg_quote($fieldName, '/') . '"[^>]*class="[^"]*)(")(.*?>)/';
|
||||
$html = preg_replace($pattern, '$1 error$2$3', $html);
|
||||
|
||||
// Handle fields without existing class attribute
|
||||
$pattern = '/(<(?:input|select|textarea)[^>]*name="' . preg_quote($fieldName, '/') . '"[^>]*?)(?:\s|>)/';
|
||||
$html = preg_replace($pattern, '$1 class="error" ', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace CSRF token placeholder using simple string replacement
|
||||
* Simplified: No DOM processing, no regex fallback - just simple string replacement
|
||||
*/
|
||||
private function replaceTokenForForm(string $html, string $formId, SessionInterface $session): string
|
||||
{
|
||||
$tokenPlaceholder = "___TOKEN_{$formId}___";
|
||||
|
||||
// Check if placeholder exists in HTML
|
||||
if (! str_contains($html, $tokenPlaceholder)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Generate token for this form
|
||||
$token = $session->csrf->generateToken($formId);
|
||||
$tokenValue = htmlspecialchars($token->toString(), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Simple string replacement
|
||||
$html = str_replace($tokenPlaceholder, $tokenValue, $html);
|
||||
|
||||
error_log("FormDataResponseProcessor: Replaced token placeholder for form_id: $formId");
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace honeypot placeholders with actual honeypot data using DOM-based processing
|
||||
*/
|
||||
private function replaceHoneypotData(string $html, string $formId, SessionInterface $session): string
|
||||
{
|
||||
$namePlaceholder = "___HONEYPOT_NAME_{$formId}___";
|
||||
$timePlaceholder = "___HONEYPOT_TIME_{$formId}___";
|
||||
|
||||
// Check if placeholders exist in HTML
|
||||
if (!str_contains($html, $namePlaceholder) && !str_contains($html, $timePlaceholder)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Generate honeypot data for this form
|
||||
$honeypotData = $session->honeypot->generateHoneypotData($formId);
|
||||
$fieldName = $honeypotData->fieldName;
|
||||
$formStartTime = (string) $honeypotData->formStartTime;
|
||||
|
||||
try {
|
||||
// Create DOMDocument with proper encoding
|
||||
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
// Suppress warnings for malformed HTML
|
||||
$libxmlPreviousState = libxml_use_internal_errors(true);
|
||||
|
||||
// Load HTML with proper flags
|
||||
$loaded = @$dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
|
||||
if (!$loaded) {
|
||||
error_log("FormDataResponseProcessor: Failed to load HTML into DOMDocument for honeypot, falling back to regex");
|
||||
libxml_use_internal_errors($libxmlPreviousState);
|
||||
return $this->replaceHoneypotDataWithRegex($html, $formId, $fieldName, $formStartTime);
|
||||
}
|
||||
|
||||
// Clear libxml errors
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($libxmlPreviousState);
|
||||
|
||||
// Create XPath
|
||||
$xpath = new \DOMXPath($dom);
|
||||
|
||||
// Find form with this form ID
|
||||
$formQuery = "//form[.//input[@name='_form_id' and @value='{$formId}']]";
|
||||
$forms = $xpath->query($formQuery);
|
||||
|
||||
if ($forms->length === 0) {
|
||||
error_log("FormDataResponseProcessor: Form with form_id '$formId' not found in DOM, falling back to regex");
|
||||
return $this->replaceHoneypotDataWithRegex($html, $formId, $fieldName, $formStartTime);
|
||||
}
|
||||
|
||||
$form = $forms->item(0);
|
||||
if (!$form instanceof \DOMElement) {
|
||||
error_log("FormDataResponseProcessor: Form element not found, falling back to regex");
|
||||
return $this->replaceHoneypotDataWithRegex($html, $formId, $fieldName, $formStartTime);
|
||||
}
|
||||
|
||||
// Replace honeypot name placeholder in name attributes and _honeypot_name value
|
||||
$nameInputs = $xpath->query(".//input[@name='_honeypot_name' or contains(@name, '{$namePlaceholder}') or contains(@value, '{$namePlaceholder}')]", $form);
|
||||
foreach ($nameInputs as $input) {
|
||||
if ($input instanceof \DOMElement) {
|
||||
// Replace in name attribute
|
||||
if ($input->hasAttribute('name') && str_contains($input->getAttribute('name'), $namePlaceholder)) {
|
||||
$input->setAttribute('name', str_replace($namePlaceholder, $fieldName, $input->getAttribute('name')));
|
||||
}
|
||||
// Replace in value attribute (for _honeypot_name hidden field)
|
||||
if ($input->hasAttribute('value') && str_contains($input->getAttribute('value'), $namePlaceholder)) {
|
||||
$input->setAttribute('value', str_replace($namePlaceholder, $fieldName, $input->getAttribute('value')));
|
||||
}
|
||||
// Replace in id attribute
|
||||
if ($input->hasAttribute('id') && str_contains($input->getAttribute('id'), $namePlaceholder)) {
|
||||
$input->setAttribute('id', str_replace($namePlaceholder, $fieldName, $input->getAttribute('id')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace in label for attribute
|
||||
$labels = $xpath->query(".//label[contains(@for, '{$namePlaceholder}')]", $form);
|
||||
foreach ($labels as $label) {
|
||||
if ($label instanceof \DOMElement && $label->hasAttribute('for')) {
|
||||
$label->setAttribute('for', str_replace($namePlaceholder, $fieldName, $label->getAttribute('for')));
|
||||
}
|
||||
}
|
||||
|
||||
// Replace time placeholder in _form_start_time value
|
||||
$timeInputs = $xpath->query(".//input[@name='_form_start_time' and contains(@value, '{$timePlaceholder}')]", $form);
|
||||
foreach ($timeInputs as $input) {
|
||||
if ($input instanceof \DOMElement) {
|
||||
$input->setAttribute('value', $formStartTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Render back to HTML
|
||||
$processedHtml = $dom->saveHTML();
|
||||
|
||||
// Remove XML declaration if present
|
||||
$processedHtml = preg_replace('/^<\?xml[^>]*\?>\s*/', '', $processedHtml);
|
||||
|
||||
error_log("FormDataResponseProcessor: Successfully processed honeypot data for form_id: $formId, field_name: $fieldName");
|
||||
|
||||
return $processedHtml;
|
||||
} catch (\Throwable $e) {
|
||||
error_log("FormDataResponseProcessor: Error processing honeypot data with DOM: " . $e->getMessage() . ", falling back to regex");
|
||||
return $this->replaceHoneypotDataWithRegex($html, $formId, $fieldName, $formStartTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace honeypot placeholders using regex (fallback)
|
||||
*/
|
||||
private function replaceHoneypotDataWithRegex(string $html, string $formId, string $fieldName, string $formStartTime): string
|
||||
{
|
||||
$namePlaceholder = "___HONEYPOT_NAME_{$formId}___";
|
||||
$timePlaceholder = "___HONEYPOT_TIME_{$formId}___";
|
||||
|
||||
// Replace name placeholder
|
||||
$html = str_replace($namePlaceholder, $fieldName, $html);
|
||||
|
||||
// Replace time placeholder
|
||||
$html = str_replace($timePlaceholder, $formStartTime, $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
88
src/Framework/View/Table/Formatters/PreviewFormatter.php
Normal file
88
src/Framework/View/Table/Formatters/PreviewFormatter.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Table\Formatters;
|
||||
|
||||
use App\Framework\View\Table\CellFormatter;
|
||||
|
||||
/**
|
||||
* Preview Formatter for Assets
|
||||
*
|
||||
* Renders thumbnail previews for images/videos in table cells
|
||||
*/
|
||||
final readonly class PreviewFormatter implements CellFormatter
|
||||
{
|
||||
public function format(mixed $value): string
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return '<span class="preview-placeholder">—</span>';
|
||||
}
|
||||
|
||||
$previewUrl = $value['preview_url'] ?? null;
|
||||
$isImage = $value['is_image'] ?? false;
|
||||
$isVideo = $value['is_video'] ?? false;
|
||||
$assetId = $value['id'] ?? null;
|
||||
|
||||
if (!$previewUrl) {
|
||||
return '<span class="preview-placeholder">No preview</span>';
|
||||
}
|
||||
|
||||
$previewHtml = '';
|
||||
|
||||
if ($isImage) {
|
||||
$previewHtml = sprintf(
|
||||
'<div class="asset-preview-thumbnail" data-asset-id="%s">
|
||||
<img src="%s" alt="Preview" loading="lazy" class="preview-image" />
|
||||
<div class="preview-overlay">
|
||||
<a href="/admin/assets/%s" class="preview-link" title="View details">👁️</a>
|
||||
</div>
|
||||
</div>',
|
||||
htmlspecialchars($assetId ?? '', ENT_QUOTES),
|
||||
htmlspecialchars($previewUrl, ENT_QUOTES),
|
||||
htmlspecialchars($assetId ?? '', ENT_QUOTES)
|
||||
);
|
||||
} elseif ($isVideo) {
|
||||
$previewHtml = sprintf(
|
||||
'<div class="asset-preview-thumbnail asset-preview-video" data-asset-id="%s">
|
||||
<div class="preview-video-icon">🎬</div>
|
||||
<div class="preview-overlay">
|
||||
<a href="/admin/assets/%s" class="preview-link" title="View details">👁️</a>
|
||||
</div>
|
||||
</div>',
|
||||
htmlspecialchars($assetId ?? '', ENT_QUOTES),
|
||||
htmlspecialchars($assetId ?? '', ENT_QUOTES)
|
||||
);
|
||||
} else {
|
||||
// File icon for other types
|
||||
$mime = $value['mime'] ?? 'application/octet-stream';
|
||||
$icon = $this->getFileIcon($mime);
|
||||
$previewHtml = sprintf(
|
||||
'<div class="asset-preview-thumbnail asset-preview-file" data-asset-id="%s">
|
||||
<div class="preview-file-icon">%s</div>
|
||||
<div class="preview-overlay">
|
||||
<a href="/admin/assets/%s" class="preview-link" title="View details">👁️</a>
|
||||
</div>
|
||||
</div>',
|
||||
htmlspecialchars($assetId ?? '', ENT_QUOTES),
|
||||
htmlspecialchars($icon, ENT_QUOTES),
|
||||
htmlspecialchars($assetId ?? '', ENT_QUOTES)
|
||||
);
|
||||
}
|
||||
|
||||
return $previewHtml;
|
||||
}
|
||||
|
||||
private function getFileIcon(string $mime): string
|
||||
{
|
||||
return match (true) {
|
||||
str_contains($mime, 'pdf') => '📄',
|
||||
str_contains($mime, 'word') => '📝',
|
||||
str_contains($mime, 'excel') => '📊',
|
||||
str_contains($mime, 'zip') || str_contains($mime, 'archive') => '📦',
|
||||
str_contains($mime, 'audio') => '🎵',
|
||||
default => '📎',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Framework\View\Table\Generators;
|
||||
|
||||
use App\Framework\Database\Browser\ValueObjects\TableMetadata;
|
||||
use App\Framework\Router\UrlGenerator;
|
||||
use App\Framework\View\Table\CellFormatter;
|
||||
use App\Framework\View\Table\Table;
|
||||
use App\Framework\View\Table\TableColumn;
|
||||
@@ -14,6 +15,9 @@ use App\Framework\View\Table\TableRow;
|
||||
|
||||
final readonly class DatabaseTableGenerator implements TableGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?UrlGenerator $urlGenerator = null
|
||||
) {}
|
||||
public function generate(object|array $data, ?TableOptions $options = null): Table
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
@@ -21,7 +25,12 @@ final readonly class DatabaseTableGenerator implements TableGenerator
|
||||
}
|
||||
|
||||
$columns = [
|
||||
TableColumn::text('name', 'Table Name', 'table-name'),
|
||||
new TableColumn(
|
||||
key: 'name',
|
||||
header: 'Table Name',
|
||||
cssClass: 'table-name',
|
||||
formatter: $this->createNameFormatter()
|
||||
),
|
||||
new TableColumn(
|
||||
key: 'row_count',
|
||||
header: 'Rows',
|
||||
@@ -125,5 +134,37 @@ final readonly class DatabaseTableGenerator implements TableGenerator
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createNameFormatter(): CellFormatter
|
||||
{
|
||||
$urlGenerator = $this->urlGenerator;
|
||||
|
||||
return new class ($urlGenerator) implements CellFormatter {
|
||||
public function __construct(
|
||||
private readonly ?UrlGenerator $urlGenerator = null
|
||||
) {}
|
||||
|
||||
public function format(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '<em class="text-muted">N/A</em>';
|
||||
}
|
||||
|
||||
$tableName = htmlspecialchars((string) $value);
|
||||
|
||||
if ($this->urlGenerator !== null) {
|
||||
try {
|
||||
$url = $this->urlGenerator->route('admin.database.table', ['table' => $tableName]);
|
||||
return "<a href=\"{$url}\" class=\"table-link\">{$tableName}</a>";
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if route not found
|
||||
return $tableName;
|
||||
}
|
||||
}
|
||||
|
||||
return $tableName;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,12 @@ final readonly class HealthCheckTableGenerator implements TableGenerator
|
||||
cssClass: 'health-status',
|
||||
formatter: $this->createStatusFormatter()
|
||||
),
|
||||
TableColumn::text('message', 'Message', 'health-message'),
|
||||
new TableColumn(
|
||||
key: 'messageFormatted',
|
||||
header: 'Message',
|
||||
cssClass: 'health-message',
|
||||
formatter: $this->createMessageFormatter()
|
||||
),
|
||||
new TableColumn(
|
||||
key: 'responseTime',
|
||||
header: 'Response Time',
|
||||
@@ -46,6 +51,13 @@ final readonly class HealthCheckTableGenerator implements TableGenerator
|
||||
// Extract status class from the check data
|
||||
$statusClass = is_array($check) ? ($check['statusClass'] ?? 'unknown') : ($check->statusClass ?? 'unknown');
|
||||
|
||||
// Ensure messageFormatted has a fallback to message
|
||||
if (is_array($check)) {
|
||||
if (!isset($check['messageFormatted']) && isset($check['message'])) {
|
||||
$check['messageFormatted'] = $check['message'];
|
||||
}
|
||||
}
|
||||
|
||||
$rows[] = TableRow::fromData($check, $columns, "health-check health-check--{$statusClass}");
|
||||
}
|
||||
|
||||
@@ -134,6 +146,32 @@ final readonly class HealthCheckTableGenerator implements TableGenerator
|
||||
};
|
||||
}
|
||||
|
||||
private function createMessageFormatter(): CellFormatter
|
||||
{
|
||||
return new class () implements CellFormatter {
|
||||
public function format(mixed $value): string
|
||||
{
|
||||
// Fallback to regular message if formatted version is not available
|
||||
if ($value === null || $value === '') {
|
||||
return '<span style="color: var(--text-muted); font-style: italic;">Keine Nachricht</span>';
|
||||
}
|
||||
|
||||
$message = (string) $value;
|
||||
|
||||
// If message contains HTML-like formatting, preserve it
|
||||
// Otherwise, escape and format nicely
|
||||
if (strip_tags($message) !== $message) {
|
||||
// Already contains HTML, return as is but wrap for word-break
|
||||
return '<div style="word-break: break-word;">' . $message . '</div>';
|
||||
}
|
||||
|
||||
// Escape and format as HTML with better styling
|
||||
$escaped = htmlspecialchars($message, ENT_QUOTES);
|
||||
return '<div style="word-break: break-word; line-height: 1.5;">' . $escaped . '</div>';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createResponseTimeFormatter(): CellFormatter
|
||||
{
|
||||
return new class () implements CellFormatter {
|
||||
|
||||
@@ -19,6 +19,14 @@ final readonly class Table
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation - renders the table HTML
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->render();
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
$options = $this->options ?? new TableOptions();
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Table;
|
||||
|
||||
use App\Framework\View\ValueObjects\AdminTableAttribute;
|
||||
|
||||
final readonly class TableColumn
|
||||
{
|
||||
public function __construct(
|
||||
@@ -22,8 +24,11 @@ final readonly class TableColumn
|
||||
$classString = $this->cssClass ? " class=\"{$this->cssClass}\"" : '';
|
||||
$styleString = $this->width ? " style=\"width: {$this->width}\"" : '';
|
||||
$sortableClass = $this->sortable ? ' sortable' : '';
|
||||
|
||||
// Add data-column attribute for JavaScript sorting
|
||||
$dataColumnAttr = $this->sortable ? ' ' . AdminTableAttribute::COLUMN->value() . "=\"{$this->key}\"" : '';
|
||||
|
||||
return "<th{$classString}{$styleString}><span class=\"header-content{$sortableClass}\">{$this->header}</span></th>";
|
||||
return "<th{$classString}{$styleString}{$dataColumnAttr}><span class=\"header-content{$sortableClass}\">{$this->header}</span></th>";
|
||||
}
|
||||
|
||||
public function createCell(mixed $value): TableCell
|
||||
|
||||
@@ -32,8 +32,20 @@ final class TemplateProcessor
|
||||
|
||||
public function render(RenderContext $context, string $html, bool $component = false): string
|
||||
{
|
||||
// Debug: Log input HTML if it contains placeholders
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($html, '{{$item')) {
|
||||
error_log("TemplateProcessor::render: Input HTML contains {{\$item: " . substr($html, 0, 300));
|
||||
}
|
||||
|
||||
// Step 1: Process strings FIRST (handles <for> loops, placeholders, etc.)
|
||||
$processedHtml = $this->processString($html, $context);
|
||||
|
||||
// Debug: Log processed HTML if it contains placeholders
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($processedHtml, '{{$item')) {
|
||||
error_log("TemplateProcessor::render: After processString, HTML contains {{\$item: " . substr($processedHtml, 0, 300));
|
||||
} elseif (getenv('APP_DEBUG') === 'true' && str_contains($html, '{{$item')) {
|
||||
error_log("TemplateProcessor::render: WARNING - {{\$item was removed by processString!");
|
||||
}
|
||||
|
||||
// Step 2: Determine which AST transformers to use based on processing mode
|
||||
$transformersToUse = $this->getTransformersForMode($context->processingMode);
|
||||
@@ -113,9 +125,22 @@ final class TemplateProcessor
|
||||
? $this->optimizeProcessorChain($this->stringProcessors, $html)
|
||||
: $this->stringProcessors;
|
||||
|
||||
// Debug: Log processor count
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($html, '{{$item')) {
|
||||
error_log("TemplateProcessor::processString: Processing with " . count($processors) . " string processor(s)");
|
||||
foreach ($processors as $processorClass) {
|
||||
error_log("TemplateProcessor::processString: Processor: " . $processorClass);
|
||||
}
|
||||
}
|
||||
|
||||
// Process through String processors (optimized order)
|
||||
foreach ($processors as $processorClass) {
|
||||
$processor = $this->resolveStringProcessor($processorClass);
|
||||
|
||||
// Debug: Log before/after each processor
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($html, '{{$item')) {
|
||||
error_log("TemplateProcessor::processString: Before {$processorClass}: " . substr($html, 0, 200));
|
||||
}
|
||||
|
||||
// Optional: Performance Tracking
|
||||
if ($this->performanceTracker !== null) {
|
||||
@@ -126,6 +151,14 @@ final class TemplateProcessor
|
||||
} else {
|
||||
$html = $processor->process($html, $context);
|
||||
}
|
||||
|
||||
// Debug: Log after each processor
|
||||
if (getenv('APP_DEBUG') === 'true' && str_contains($html, '{{$item')) {
|
||||
error_log("TemplateProcessor::processString: After {$processorClass}: " . substr($html, 0, 200));
|
||||
} elseif (getenv('APP_DEBUG') === 'true' && str_contains($html, '{{$item') === false && str_contains($html, '{{$item') === false) {
|
||||
// Check if it was removed
|
||||
$previousHtml = $html; // This won't work, we need to track it differently
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
|
||||
@@ -8,16 +8,17 @@ use App\Framework\Cache\Cache;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\View\Dom\Transformer\AssetInjectorTransformer;
|
||||
use App\Framework\View\Dom\Transformer\BooleanAttributeTransformer;
|
||||
use App\Framework\View\Dom\Transformer\CommentStripTransformer;
|
||||
use App\Framework\View\Dom\Transformer\FormTransformer;
|
||||
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;
|
||||
use App\Framework\View\Dom\Transformer\MetaManipulatorTransformer;
|
||||
use App\Framework\View\Dom\Transformer\PlaceholderTransformer;
|
||||
use App\Framework\View\Dom\Transformer\WhitespaceCleanupTransformer;
|
||||
use App\Framework\View\Dom\Transformer\XComponentTransformer;
|
||||
use App\Framework\View\Processors\PlaceholderReplacer;
|
||||
use App\Framework\View\Processors\VoidElementsSelfClosingProcessor;
|
||||
|
||||
final readonly class TemplateProcessorInitializer
|
||||
{
|
||||
@@ -30,20 +31,26 @@ final readonly class TemplateProcessorInitializer
|
||||
{
|
||||
$astTransformers = [
|
||||
LayoutTagTransformer::class,
|
||||
XComponentTransformer::class,
|
||||
ForTransformer::class,
|
||||
ForTransformer::class, // Erstellt Scopes für Loop-Variablen
|
||||
PlaceholderTransformer::class, // Verarbeitet Platzhalter (MUSS vor XComponentTransformer laufen)
|
||||
XComponentTransformer::class, // Rendert Components (nach PlaceholderTransformer, damit Platzhalter bereits ersetzt sind)
|
||||
IfTransformer::class,
|
||||
BooleanAttributeTransformer::class, // Entfernt boolean Attribute mit Wert "false" (nach PlaceholderTransformer)
|
||||
MetaManipulatorTransformer::class,
|
||||
AssetInjectorTransformer::class,
|
||||
HoneypotTransformer::class,
|
||||
FormTransformer::class, // CSRF-Tokens, Form-IDs, Old-Input, Error-Platzhalter
|
||||
HoneypotTransformer::class, // Honeypot-Felder (nach FormTransformer)
|
||||
CommentStripTransformer::class,
|
||||
WhitespaceCleanupTransformer::class,
|
||||
];
|
||||
|
||||
$stringProcessors = [
|
||||
PlaceholderReplacer::class,
|
||||
VoidElementsSelfClosingProcessor::class,
|
||||
];
|
||||
// StringProcessors removed - all processing now happens via AST Transformers
|
||||
// This ensures consistent processing on structured DOM instead of raw strings
|
||||
// Benefits:
|
||||
// - No string manipulation before parsing (prevents lexer/parser issues)
|
||||
// - Consistent processing pipeline (only AST transformers)
|
||||
// - Better error handling and debugging
|
||||
$stringProcessors = [];
|
||||
|
||||
$chainOptimizer = new ProcessorChainOptimizer($cache);
|
||||
$compiledTemplateCache = new CompiledTemplateCache($cache);
|
||||
|
||||
46
src/Framework/View/ValueObjects/ActionHandlerAttribute.php
Normal file
46
src/Framework/View/ValueObjects/ActionHandlerAttribute.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* Action Handler Attributes
|
||||
*
|
||||
* Data attributes for the ActionHandler system.
|
||||
* Note: data-action-param-* is a pattern and not included as enum case.
|
||||
*/
|
||||
enum ActionHandlerAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Action configuration
|
||||
case ACTION = 'data-action';
|
||||
case ACTION_HANDLER = 'data-action-handler';
|
||||
case ACTION_URL = 'data-action-url';
|
||||
case ACTION_METHOD = 'data-action-method';
|
||||
case ACTION_TYPE = 'data-action-type';
|
||||
case ACTION_HANDLER_CONTAINER = 'data-action-handler-container';
|
||||
|
||||
// Action options
|
||||
case ACTION_CONFIRM = 'data-action-confirm';
|
||||
case ACTION_LOADING_TEXT = 'data-action-loading-text';
|
||||
case ACTION_SUCCESS_TOAST = 'data-action-success-toast';
|
||||
case ACTION_ERROR_TOAST = 'data-action-error-toast';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
51
src/Framework/View/ValueObjects/AdminTableAttribute.php
Normal file
51
src/Framework/View/ValueObjects/AdminTableAttribute.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* Admin Table Attributes
|
||||
*
|
||||
* Data attributes for admin table functionality including sorting,
|
||||
* pagination, searching, and column configuration.
|
||||
*/
|
||||
enum AdminTableAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Table configuration
|
||||
case RESOURCE = 'data-resource';
|
||||
case API_ENDPOINT = 'data-api-endpoint';
|
||||
case SORTABLE = 'data-sortable';
|
||||
case SEARCHABLE = 'data-searchable';
|
||||
case PAGINATED = 'data-paginated';
|
||||
case PER_PAGE = 'data-per-page';
|
||||
|
||||
// Column configuration
|
||||
case COLUMN = 'data-column';
|
||||
case SORT_DIR = 'data-sort-dir';
|
||||
|
||||
// Pagination
|
||||
case PAGE = 'data-page';
|
||||
|
||||
// Search and pagination containers
|
||||
case TABLE_SEARCH = 'data-table-search';
|
||||
case TABLE_PAGINATION = 'data-table-pagination';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Framework/View/ValueObjects/BulkOperationAttribute.php
Normal file
47
src/Framework/View/ValueObjects/BulkOperationAttribute.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* Bulk Operation Attributes
|
||||
*
|
||||
* Data attributes for bulk operations on admin tables.
|
||||
*/
|
||||
enum BulkOperationAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Bulk operations configuration
|
||||
case BULK_OPERATIONS = 'data-bulk-operations';
|
||||
case BULK_TOOLBAR = 'data-bulk-toolbar';
|
||||
case BULK_COUNT = 'data-bulk-count';
|
||||
case BULK_BUTTONS = 'data-bulk-buttons';
|
||||
case BULK_INITIALIZED = 'data-bulk-initialized';
|
||||
|
||||
// Bulk selection
|
||||
case BULK_SELECT_ALL = 'data-bulk-select-all';
|
||||
case BULK_ITEM_ID = 'data-bulk-item-id';
|
||||
|
||||
// Bulk actions
|
||||
case BULK_ACTION = 'data-bulk-action';
|
||||
case BULK_METHOD = 'data-bulk-method';
|
||||
case BULK_CONFIRM = 'data-bulk-confirm';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
31
src/Framework/View/ValueObjects/DataAttributeHelper.php
Normal file
31
src/Framework/View/ValueObjects/DataAttributeHelper.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* Helper functions for working with data attributes
|
||||
*
|
||||
* Provides convenience methods for converting data attribute enums to strings
|
||||
* and working with both string and enum attribute names.
|
||||
*/
|
||||
final readonly class DataAttributeHelper
|
||||
{
|
||||
/**
|
||||
* Convert attribute name to string
|
||||
*
|
||||
* Accepts both string and DataAttributeInterface for convenience.
|
||||
* This allows methods to accept either format without requiring ->value() calls.
|
||||
*
|
||||
* @param string|DataAttributeInterface $attribute
|
||||
* @return string
|
||||
*/
|
||||
public static function toString(string|DataAttributeInterface $attribute): string
|
||||
{
|
||||
return $attribute instanceof DataAttributeInterface
|
||||
? $attribute->value()
|
||||
: $attribute;
|
||||
}
|
||||
}
|
||||
|
||||
36
src/Framework/View/ValueObjects/DataAttributeInterface.php
Normal file
36
src/Framework/View/ValueObjects/DataAttributeInterface.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* Interface for all data-* attribute enums
|
||||
*
|
||||
* Ensures consistent structure across all data attribute enums.
|
||||
* All enums implementing this interface provide:
|
||||
* - The attribute name (e.g., "data-live-component")
|
||||
* - CSS selector conversion (e.g., "[data-live-component]")
|
||||
* - JavaScript dataset key conversion (e.g., "liveComponent")
|
||||
*/
|
||||
interface DataAttributeInterface
|
||||
{
|
||||
/**
|
||||
* Get the attribute name (e.g., "data-live-component")
|
||||
*/
|
||||
public function value(): string;
|
||||
|
||||
/**
|
||||
* Convert to CSS selector (e.g., "[data-live-component]")
|
||||
*/
|
||||
public function toSelector(): string;
|
||||
|
||||
/**
|
||||
* Convert to JavaScript dataset key (e.g., "liveComponent")
|
||||
*
|
||||
* Converts kebab-case to camelCase and removes "data-" prefix.
|
||||
* Example: "data-live-component" -> "liveComponent"
|
||||
*/
|
||||
public function toDatasetKey(): string;
|
||||
}
|
||||
|
||||
39
src/Framework/View/ValueObjects/FormDataAttribute.php
Normal file
39
src/Framework/View/ValueObjects/FormDataAttribute.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* Form Data Attributes
|
||||
*
|
||||
* Data attributes for form handling and validation.
|
||||
* Note: data-param-* is a pattern and not included as enum case.
|
||||
*/
|
||||
enum FormDataAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Form field identification
|
||||
case FIELD = 'data-field';
|
||||
|
||||
// Form state preservation
|
||||
case SELECTED_IF = 'data-selected-if';
|
||||
case CHECKED_IF = 'data-checked-if';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,33 @@ final readonly class FormElement implements HtmlElement
|
||||
return new self(HtmlTag::textarea(), $attributes, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a form element from tag name, attributes array, and content
|
||||
*
|
||||
* @param string $tagName Tag name (e.g., 'div', 'label', 'select')
|
||||
* @param array<string, string|null> $attributesArray Attributes as key-value pairs
|
||||
* @param string $content Element content
|
||||
*/
|
||||
public static function create(string $tagName, array $attributesArray = [], string $content = ''): self
|
||||
{
|
||||
$tag = match (strtolower($tagName)) {
|
||||
'form' => HtmlTag::form(),
|
||||
'input' => HtmlTag::input(),
|
||||
'button' => HtmlTag::button(),
|
||||
'label' => HtmlTag::label(),
|
||||
'textarea' => HtmlTag::textarea(),
|
||||
'select' => HtmlTag::select(),
|
||||
'option' => HtmlTag::option(),
|
||||
'div' => HtmlTag::div(),
|
||||
'small' => new HtmlTag(TagName::SMALL),
|
||||
default => throw new \InvalidArgumentException("Unsupported form element tag: {$tagName}")
|
||||
};
|
||||
|
||||
$attributes = HtmlAttributes::fromArray($attributesArray);
|
||||
|
||||
return new self($tag, $attributes, $content);
|
||||
}
|
||||
|
||||
public function withAttribute(string $name, ?string $value = null): self
|
||||
{
|
||||
return new self($this->tag, $this->attributes->with($name, $value), $this->content);
|
||||
|
||||
166
src/Framework/View/ValueObjects/HtmlAttribute.php
Normal file
166
src/Framework/View/ValueObjects/HtmlAttribute.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* HTML Attribute Value Object
|
||||
*
|
||||
* Represents a single HTML attribute with name and optional value.
|
||||
* Supports both regular attributes (with value) and boolean/flag attributes (without value).
|
||||
*
|
||||
* Features:
|
||||
* - Type-safe attribute names (supports DataAttributeInterface enums)
|
||||
* - Validation of attribute names
|
||||
* - Explicit distinction between flag and value attributes
|
||||
* - Proper HTML escaping
|
||||
*/
|
||||
final readonly class HtmlAttribute
|
||||
{
|
||||
private string $nameString;
|
||||
|
||||
public string $name;
|
||||
|
||||
/**
|
||||
* @param string|DataAttributeInterface $name Attribute name
|
||||
* @param string|null $value Attribute value (null for boolean/flag attributes)
|
||||
*/
|
||||
public function __construct(
|
||||
string|DataAttributeInterface $name,
|
||||
public ?string $value = null
|
||||
) {
|
||||
// Normalize name to string
|
||||
$this->nameString = DataAttributeHelper::toString($name);
|
||||
|
||||
$this->name = DataAttributeHelper::toString($name);
|
||||
|
||||
// Validate attribute name
|
||||
$this->validateName($this->nameString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create attribute with value
|
||||
*/
|
||||
public static function withValue(string|DataAttributeInterface $name, string $value): self
|
||||
{
|
||||
return new self($name, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create boolean/flag attribute (no value)
|
||||
*/
|
||||
public static function flag(string|DataAttributeInterface $name): self
|
||||
{
|
||||
return new self($name, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from DataAttributeInterface enum
|
||||
*/
|
||||
public static function fromDataAttribute(DataAttributeInterface $attribute, ?string $value = null): self
|
||||
{
|
||||
return new self($attribute, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create id attribute
|
||||
*/
|
||||
public static function id(string $id): self
|
||||
{
|
||||
return self::withValue('id', $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create class attribute
|
||||
*/
|
||||
public static function class(string $class): self
|
||||
{
|
||||
return self::withValue('class', $class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data-* attribute
|
||||
*/
|
||||
public static function data(string $name, string $value): self
|
||||
{
|
||||
return self::withValue("data-{$name}", $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attribute name as string
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return $this->nameString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attribute value (null for flag attributes)
|
||||
*/
|
||||
public function value(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a boolean/flag attribute (no value)
|
||||
*/
|
||||
public function isFlag(): bool
|
||||
{
|
||||
return $this->value === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if attribute has a value
|
||||
*/
|
||||
public function hasValue(): bool
|
||||
{
|
||||
return $this->value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to HTML string
|
||||
*
|
||||
* Examples:
|
||||
* - Flag: "disabled" -> "disabled"
|
||||
* - Value: "id=\"test\"" -> "id=\"test\""
|
||||
*/
|
||||
public function toHtml(): string
|
||||
{
|
||||
if ($this->isFlag()) {
|
||||
return $this->nameString;
|
||||
}
|
||||
|
||||
$escapedValue = htmlspecialchars($this->value ?? '', ENT_QUOTES, 'UTF-8');
|
||||
return sprintf('%s="%s"', $this->nameString, $escapedValue);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate attribute name
|
||||
*
|
||||
* @throws \InvalidArgumentException if attribute name is invalid
|
||||
*/
|
||||
private function validateName(string $name): void
|
||||
{
|
||||
if ($name === '') {
|
||||
throw new \InvalidArgumentException('Attribute name cannot be empty');
|
||||
}
|
||||
|
||||
// Basic validation: no spaces, no special characters that would break HTML
|
||||
if (preg_match('/[\s<>"\'=]/', $name)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf(
|
||||
'Invalid attribute name: "%s". Attribute names cannot contain spaces, quotes, or special characters.',
|
||||
$name
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ namespace App\Framework\View\ValueObjects;
|
||||
final readonly class HtmlAttributes
|
||||
{
|
||||
/**
|
||||
* @param array<string, string|null> $attributes
|
||||
* @param array<string, HtmlAttribute> $attributes
|
||||
*/
|
||||
public function __construct(
|
||||
public array $attributes = []
|
||||
private array $attributes = []
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -21,15 +21,27 @@ final readonly class HtmlAttributes
|
||||
|
||||
public static function fromArray(array $attributes): self
|
||||
{
|
||||
return new self($attributes);
|
||||
$htmlAttributes = [];
|
||||
foreach ($attributes as $name => $value) {
|
||||
$htmlAttributes[$name] = $value === null
|
||||
? HtmlAttribute::flag($name)
|
||||
: HtmlAttribute::withValue($name, $value);
|
||||
}
|
||||
return new self($htmlAttributes);
|
||||
}
|
||||
|
||||
public function with(string $name, ?string $value = null): self
|
||||
public function with(string|DataAttributeInterface $name, ?string $value = null): self
|
||||
{
|
||||
return new self([...$this->attributes, $name => $value]);
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
$newAttributes = $this->attributes;
|
||||
$newAttributes[$nameString] = $value === null
|
||||
? HtmlAttribute::flag($name)
|
||||
: HtmlAttribute::withValue($name, $value);
|
||||
|
||||
return new self($newAttributes);
|
||||
}
|
||||
|
||||
public function withFlag(string $name): self
|
||||
public function withFlag(string|DataAttributeInterface $name): self
|
||||
{
|
||||
return $this->with($name, null);
|
||||
}
|
||||
@@ -44,6 +56,38 @@ final readonly class HtmlAttributes
|
||||
return $this->with('class', $class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one or more CSS classes to the existing class attribute
|
||||
*
|
||||
* @param string ...$classes Classes to add
|
||||
*/
|
||||
public function addClass(string ...$classes): self
|
||||
{
|
||||
$currentClasses = $this->get('class') ?? '';
|
||||
$classArray = array_filter(explode(' ', $currentClasses));
|
||||
$newClasses = array_unique([...$classArray, ...$classes]);
|
||||
return $this->with('class', implode(' ', $newClasses));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one or more CSS classes from the class attribute
|
||||
*
|
||||
* @param string ...$classes Classes to remove
|
||||
*/
|
||||
public function removeClass(string ...$classes): self
|
||||
{
|
||||
$currentClasses = $this->get('class') ?? '';
|
||||
if ($currentClasses === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$classArray = array_filter(explode(' ', $currentClasses));
|
||||
$newClasses = array_diff($classArray, $classes);
|
||||
return empty($newClasses)
|
||||
? $this->without('class')
|
||||
: $this->with('class', implode(' ', $newClasses));
|
||||
}
|
||||
|
||||
public function withType(string $type): self
|
||||
{
|
||||
return $this->with('type', $type);
|
||||
@@ -94,19 +138,100 @@ final readonly class HtmlAttributes
|
||||
return $this->withFlag('selected');
|
||||
}
|
||||
|
||||
public function get(string $name): ?string
|
||||
public function get(string|DataAttributeInterface $name): ?string
|
||||
{
|
||||
return $this->attributes[$name] ?? null;
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
return $this->attributes[$nameString]?->value();
|
||||
}
|
||||
|
||||
public function has(string $name): bool
|
||||
public function has(string|DataAttributeInterface $name): bool
|
||||
{
|
||||
return array_key_exists($name, $this->attributes);
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
return isset($this->attributes[$nameString]);
|
||||
}
|
||||
|
||||
public function isFlag(string $name): bool
|
||||
public function isFlag(string|DataAttributeInterface $name): bool
|
||||
{
|
||||
return $this->has($name) && $this->get($name) === null;
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
return $this->attributes[$nameString]?->isFlag() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attribute as HtmlAttribute object
|
||||
*/
|
||||
public function getAttribute(string|DataAttributeInterface $name): ?HtmlAttribute
|
||||
{
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
return $this->attributes[$nameString] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one or more attributes
|
||||
*
|
||||
* @param string|DataAttributeInterface ...$names Attribute names to remove
|
||||
*/
|
||||
public function without(string|DataAttributeInterface ...$names): self
|
||||
{
|
||||
$nameStrings = array_map(
|
||||
fn($name) => DataAttributeHelper::toString($name),
|
||||
$names
|
||||
);
|
||||
|
||||
$newAttributes = array_filter(
|
||||
$this->attributes,
|
||||
fn($name) => !in_array($name, $nameStrings, true),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
|
||||
return new self($newAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with another HtmlAttributes object
|
||||
*
|
||||
* @param HtmlAttributes $other Attributes to merge
|
||||
* @param bool $otherTakesPriority If true, other attributes override existing ones
|
||||
*/
|
||||
public function merge(HtmlAttributes $other, bool $otherTakesPriority = false): self
|
||||
{
|
||||
return $otherTakesPriority
|
||||
? new self([...$this->attributes, ...$other->attributes])
|
||||
: new self([...$other->attributes, ...$this->attributes]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple attributes at once
|
||||
*
|
||||
* @param array<string|DataAttributeInterface, string|null> $attributes
|
||||
*/
|
||||
public function withMany(array $attributes): self
|
||||
{
|
||||
$newAttributes = $this->attributes;
|
||||
foreach ($attributes as $name => $value) {
|
||||
$nameString = DataAttributeHelper::toString($name);
|
||||
$newAttributes[$nameString] = $value === null
|
||||
? HtmlAttribute::flag($name)
|
||||
: HtmlAttribute::withValue($name, $value);
|
||||
}
|
||||
return new self($newAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array format (name => value)
|
||||
*
|
||||
* Returns array where:
|
||||
* - Flag attributes have null as value
|
||||
* - Value attributes have their string value
|
||||
*
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->attributes as $name => $attribute) {
|
||||
$result[$name] = $attribute->value();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
@@ -116,15 +241,8 @@ final readonly class HtmlAttributes
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($this->attributes as $name => $value) {
|
||||
if ($value === null) {
|
||||
// Boolean/flag attribute without value
|
||||
$parts[] = $name;
|
||||
} else {
|
||||
// Attribute with value
|
||||
$escapedValue = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
$parts[] = "{$name}=\"{$escapedValue}\"";
|
||||
}
|
||||
foreach ($this->attributes as $attribute) {
|
||||
$parts[] = $attribute->toHtml();
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* LiveComponent Core Attributes
|
||||
*
|
||||
* Core data-* attributes used by LiveComponents for component identification,
|
||||
* state management, security, and real-time communication.
|
||||
*/
|
||||
enum LiveComponentCoreAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Component identification
|
||||
case LIVE_COMPONENT = 'data-live-component';
|
||||
case COMPONENT_ID = 'data-component-id';
|
||||
case LIVE_ID = 'data-live-id';
|
||||
|
||||
// State and data
|
||||
case STATE = 'data-state';
|
||||
case LIVE_STATE = 'data-live-state';
|
||||
case LIVE_CONTENT = 'data-live-content';
|
||||
|
||||
// Actions
|
||||
case LIVE_ACTION = 'data-live-action';
|
||||
|
||||
// Security
|
||||
case CSRF_TOKEN = 'data-csrf-token';
|
||||
|
||||
// Real-time communication
|
||||
case SSE_CHANNEL = 'data-sse-channel';
|
||||
case POLL_INTERVAL = 'data-poll-interval';
|
||||
|
||||
// File uploads
|
||||
case LIVE_UPLOAD = 'data-live-upload';
|
||||
case LIVE_DROPZONE = 'data-live-dropzone';
|
||||
|
||||
// Accessibility
|
||||
case LIVE_POLITE = 'data-live-polite';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* LiveComponent Feature Attributes
|
||||
*
|
||||
* Advanced feature attributes (data-lc-*) for LiveComponents including
|
||||
* data binding, fragments, URL management, transitions, and triggers.
|
||||
*/
|
||||
enum LiveComponentFeatureAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Data binding
|
||||
case LC_MODEL = 'data-lc-model';
|
||||
|
||||
// Fragments
|
||||
case LC_FRAGMENT = 'data-lc-fragment';
|
||||
case LC_FRAGMENTS = 'data-lc-fragments';
|
||||
|
||||
// Element identification
|
||||
case LC_KEY = 'data-lc-key';
|
||||
|
||||
// Progressive enhancement
|
||||
case LC_BOOST = 'data-lc-boost';
|
||||
|
||||
// URL management
|
||||
case LC_PUSH_URL = 'data-lc-push-url';
|
||||
case LC_REPLACE_URL = 'data-lc-replace-url';
|
||||
|
||||
// DOM updates
|
||||
case LC_TARGET = 'data-lc-target';
|
||||
case LC_SWAP = 'data-lc-swap';
|
||||
case LC_SWAP_TRANSITION = 'data-lc-swap-transition';
|
||||
|
||||
// Accessibility
|
||||
case LC_KEEP_FOCUS = 'data-lc-keep-focus';
|
||||
|
||||
// Scrolling
|
||||
case LC_SCROLL = 'data-lc-scroll';
|
||||
case LC_SCROLL_TARGET = 'data-lc-scroll-target';
|
||||
case LC_SCROLL_BEHAVIOR = 'data-lc-scroll-behavior';
|
||||
|
||||
// Loading indicators
|
||||
case LC_INDICATOR = 'data-lc-indicator';
|
||||
|
||||
// Trigger options
|
||||
case LC_TRIGGER_DELAY = 'data-lc-trigger-delay';
|
||||
case LC_TRIGGER_THROTTLE = 'data-lc-trigger-throttle';
|
||||
case LC_TRIGGER_ONCE = 'data-lc-trigger-once';
|
||||
case LC_TRIGGER_CHANGED = 'data-lc-trigger-changed';
|
||||
case LC_TRIGGER_FROM = 'data-lc-trigger-from';
|
||||
case LC_TRIGGER_LOAD = 'data-lc-trigger-load';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* LiveComponent Lazy Loading Attributes
|
||||
*
|
||||
* Attributes for lazy loading and island rendering of LiveComponents.
|
||||
*/
|
||||
enum LiveComponentLazyAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Lazy loading
|
||||
case LIVE_COMPONENT_LAZY = 'data-live-component-lazy';
|
||||
case LIVE_COMPONENT_ISLAND = 'data-live-component-island';
|
||||
case ISLAND_COMPONENT = 'data-island-component';
|
||||
|
||||
// Lazy loading options
|
||||
case LAZY_PRIORITY = 'data-lazy-priority';
|
||||
case LAZY_THRESHOLD = 'data-lazy-threshold';
|
||||
case LAZY_PLACEHOLDER = 'data-lazy-placeholder';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
35
src/Framework/View/ValueObjects/ModuleDataAttribute.php
Normal file
35
src/Framework/View/ValueObjects/ModuleDataAttribute.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* Module System Attributes
|
||||
*
|
||||
* Data attributes for the JavaScript module initialization system.
|
||||
*/
|
||||
enum ModuleDataAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Module initialization
|
||||
case MODULE = 'data-module';
|
||||
case OPTIONS = 'data-options';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
141
src/Framework/View/ValueObjects/ScopeStack.php
Normal file
141
src/Framework/View/ValueObjects/ScopeStack.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* ScopeStack - Value Object representing a stack of template scopes
|
||||
*
|
||||
* Manages nested scopes for template rendering, where inner scopes
|
||||
* (from nested loops) have priority over outer scopes.
|
||||
*
|
||||
* The stack is ordered with inner scopes first (highest priority).
|
||||
*
|
||||
* Framework Pattern: readonly Value Object, immutable by design
|
||||
*/
|
||||
final readonly class ScopeStack
|
||||
{
|
||||
/**
|
||||
* @param TemplateScope[] $scopes Array of scopes, inner scopes first (highest priority)
|
||||
*/
|
||||
public function __construct(
|
||||
public array $scopes = []
|
||||
) {
|
||||
// Validate: all items must be TemplateScope instances
|
||||
foreach ($this->scopes as $index => $scope) {
|
||||
if (!$scope instanceof TemplateScope) {
|
||||
throw new \InvalidArgumentException(
|
||||
"ScopeStack: All scopes must be TemplateScope instances, " .
|
||||
"got " . gettype($scope) . " at index {$index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty scope stack
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a new scope onto the stack (creates new instance)
|
||||
*
|
||||
* The new scope will have highest priority (searched first).
|
||||
*
|
||||
* @param TemplateScope|array<string, mixed> $scope TemplateScope instance or array of variables
|
||||
* @return self New ScopeStack with added scope
|
||||
*/
|
||||
public function push(TemplateScope|array $scope): self
|
||||
{
|
||||
$templateScope = $scope instanceof TemplateScope
|
||||
? $scope
|
||||
: TemplateScope::fromArray($scope);
|
||||
|
||||
return new self(array_merge([$templateScope], $this->scopes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a variable name from the scope stack
|
||||
*
|
||||
* Searches from inner scopes (highest priority) to outer scopes.
|
||||
*
|
||||
* @param string $name Variable name (without $)
|
||||
* @return mixed Variable value or null if not found
|
||||
*/
|
||||
public function resolve(string $name): mixed
|
||||
{
|
||||
// Search from inner scopes (first in array) to outer scopes
|
||||
foreach ($this->scopes as $scope) {
|
||||
if ($scope->has($name)) {
|
||||
return $scope->get($name);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all variables from all scopes merged together
|
||||
*
|
||||
* Inner scopes override outer scopes (inner scopes have priority).
|
||||
* This is useful for ExpressionEvaluator which expects a flat array.
|
||||
*
|
||||
* @return array<string, mixed> All variables with scope priority applied
|
||||
*/
|
||||
public function getAllVariables(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
// Merge from outer to inner (so inner scopes override outer)
|
||||
foreach (array_reverse($this->scopes) as $scope) {
|
||||
$result = array_merge($result, $scope->toArray());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variable exists in any scope
|
||||
*/
|
||||
public function has(string $name): bool
|
||||
{
|
||||
foreach ($this->scopes as $scope) {
|
||||
if ($scope->has($name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of scopes in stack
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stack is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scopes as array (for debugging/inspection)
|
||||
*
|
||||
* @return TemplateScope[]
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->scopes;
|
||||
}
|
||||
}
|
||||
|
||||
41
src/Framework/View/ValueObjects/StateDataAttribute.php
Normal file
41
src/Framework/View/ValueObjects/StateDataAttribute.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* State Management Attributes
|
||||
*
|
||||
* Data attributes for client-side state management and data binding.
|
||||
*/
|
||||
enum StateDataAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// State binding
|
||||
case BIND = 'data-bind';
|
||||
case BIND_ATTR = 'data-bind-attr';
|
||||
case BIND_ATTR_NAME = 'data-bind-attr-name';
|
||||
case BIND_CLASS = 'data-bind-class';
|
||||
case BIND_INPUT = 'data-bind-input';
|
||||
|
||||
// State persistence
|
||||
case PERSISTENT = 'data-persistent';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ enum TagName: string
|
||||
case THEAD = 'thead';
|
||||
case TBODY = 'tbody';
|
||||
case TFOOT = 'tfoot';
|
||||
case SMALL = 'small';
|
||||
|
||||
public function isSelfClosing(): bool
|
||||
{
|
||||
|
||||
108
src/Framework/View/ValueObjects/TemplateScope.php
Normal file
108
src/Framework/View/ValueObjects/TemplateScope.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* TemplateScope - Value Object representing a template variable scope
|
||||
*
|
||||
* Used for nested loops in templates where loop variables need to be scoped.
|
||||
* Each scope contains variables that are available within that loop iteration.
|
||||
*
|
||||
* Framework Pattern: readonly Value Object, immutable by design
|
||||
*/
|
||||
final readonly class TemplateScope
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $variables Template variables in this scope
|
||||
* Keys must be strings (variable names)
|
||||
*/
|
||||
public function __construct(
|
||||
public array $variables = []
|
||||
) {
|
||||
// Validate: all keys must be strings
|
||||
foreach (array_keys($this->variables) as $key) {
|
||||
if (!is_string($key)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"TemplateScope: All variable keys must be strings, got " . gettype($key)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create scope from array (convenience method)
|
||||
*
|
||||
* @param array<string, mixed> $variables
|
||||
*/
|
||||
public static function fromArray(array $variables): self
|
||||
{
|
||||
return new self($variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty scope
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variable exists in scope
|
||||
*/
|
||||
public function has(string $name): bool
|
||||
{
|
||||
return array_key_exists($name, $this->variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variable value from scope
|
||||
*
|
||||
* @param string $name Variable name
|
||||
* @param mixed $default Default value if variable not found
|
||||
* @return mixed Variable value or default
|
||||
*/
|
||||
public function get(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return $this->variables[$name] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert scope to array (for merging with other data)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all variable names in scope
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getVariableNames(): array
|
||||
{
|
||||
return array_keys($this->variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scope is empty
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of variables in scope
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->variables);
|
||||
}
|
||||
}
|
||||
|
||||
66
src/Framework/View/ValueObjects/UIDataAttribute.php
Normal file
66
src/Framework/View/ValueObjects/UIDataAttribute.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\ValueObjects;
|
||||
|
||||
/**
|
||||
* UI Enhancement Attributes
|
||||
*
|
||||
* Data attributes for UI enhancements including loading states,
|
||||
* optimistic updates, confirmations, modals, themes, and other UI features.
|
||||
*/
|
||||
enum UIDataAttribute: string implements DataAttributeInterface
|
||||
{
|
||||
// Loading states
|
||||
case LOADING = 'data-loading';
|
||||
case LOADING_TEXT = 'data-loading-text';
|
||||
case ORIGINAL_TEXT = 'data-original-text';
|
||||
|
||||
// Optimistic updates
|
||||
case OPTIMISTIC = 'data-optimistic';
|
||||
case ROLLBACK = 'data-rollback';
|
||||
|
||||
// Confirmations
|
||||
case CONFIRM_OK = 'data-confirm-ok';
|
||||
case CONFIRM_CANCEL = 'data-confirm-cancel';
|
||||
|
||||
// Modals
|
||||
case CLOSE_MODAL = 'data-close-modal';
|
||||
|
||||
// Tags
|
||||
case TAG = 'data-tag';
|
||||
|
||||
// Theme
|
||||
case THEME = 'data-theme';
|
||||
case THEME_ICON = 'data-theme-icon';
|
||||
|
||||
// Navigation
|
||||
case MOBILE_MENU_OPEN = 'data-mobile-menu-open';
|
||||
case SECTION_ID = 'data-section-id';
|
||||
case TAB = 'data-tab';
|
||||
|
||||
// Views
|
||||
case VIEW_MODE = 'data-view-mode';
|
||||
|
||||
// Assets
|
||||
case ASSET_ID = 'data-asset-id';
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toSelector(): string
|
||||
{
|
||||
return '[' . $this->value . ']';
|
||||
}
|
||||
|
||||
public function toDatasetKey(): string
|
||||
{
|
||||
// Remove "data-" prefix and convert kebab-case to camelCase
|
||||
$key = substr($this->value, 5); // Remove "data-"
|
||||
return str_replace('-', '', ucwords($key, '-'));
|
||||
}
|
||||
}
|
||||
|
||||
281
src/Framework/View/templates/admin-asset-gallery.view.php
Normal file
281
src/Framework/View/templates/admin-asset-gallery.view.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<div class="admin-asset-gallery">
|
||||
<!-- Header -->
|
||||
<div class="admin-asset-gallery__header">
|
||||
<h1 class="admin-asset-gallery__title">Asset Gallery</h1>
|
||||
<div class="admin-asset-gallery__actions">
|
||||
<div class="admin-asset-gallery__view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-asset-gallery__view-toggle-button {{view_mode === 'grid' ? 'admin-asset-gallery__view-toggle-button--active' : ''}}"
|
||||
data-live-action="toggleViewMode"
|
||||
data-param-view-mode="grid"
|
||||
>
|
||||
<i class="icon-grid"></i> Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-asset-gallery__view-toggle-button {{view_mode === 'list' ? 'admin-asset-gallery__view-toggle-button--active' : ''}}"
|
||||
data-live-action="toggleViewMode"
|
||||
data-param-view-mode="list"
|
||||
>
|
||||
<i class="icon-list"></i> List
|
||||
</button>
|
||||
</div>
|
||||
<a href="/admin/assets/create" class="admin-btn admin-btn--primary">
|
||||
<i class="icon-upload"></i> Upload Asset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="admin-asset-gallery__content">
|
||||
<!-- Filters Sidebar -->
|
||||
<aside class="admin-asset-gallery__filters-sidebar">
|
||||
<h2 class="admin-asset-gallery__filters-title">Filters</h2>
|
||||
|
||||
<div class="admin-asset-gallery__filter-group">
|
||||
<label class="admin-asset-gallery__filter-label">MIME Type</label>
|
||||
<select
|
||||
class="admin-asset-gallery__filter-select"
|
||||
data-live-action="filterByMimeType"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="image/jpeg" {{filter_mime_type === 'image/jpeg' ? 'selected' : ''}}>JPEG</option>
|
||||
<option value="image/png" {{filter_mime_type === 'image/png' ? 'selected' : ''}}>PNG</option>
|
||||
<option value="image/webp" {{filter_mime_type === 'image/webp' ? 'selected' : ''}}>WebP</option>
|
||||
<option value="image/gif" {{filter_mime_type === 'image/gif' ? 'selected' : ''}}>GIF</option>
|
||||
<option value="video/mp4" {{filter_mime_type === 'video/mp4' ? 'selected' : ''}}>MP4</option>
|
||||
<option value="video/webm" {{filter_mime_type === 'video/webm' ? 'selected' : ''}}>WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-asset-gallery__filter-group">
|
||||
<label class="admin-asset-gallery__filter-label">Bucket</label>
|
||||
<input
|
||||
type="text"
|
||||
class="admin-asset-gallery__filter-input"
|
||||
data-live-action="filterByBucket"
|
||||
data-live-debounce="500"
|
||||
placeholder="Filter by bucket"
|
||||
value="{{filter_bucket}}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="admin-asset-gallery__filter-group">
|
||||
<label class="admin-asset-gallery__filter-label">Collection</label>
|
||||
<select
|
||||
class="admin-asset-gallery__filter-select"
|
||||
data-live-action="filterByCollection"
|
||||
>
|
||||
<option value="">All Collections</option>
|
||||
<for items="collections" as="collection">
|
||||
<option
|
||||
value="{{collection.id}}"
|
||||
{{filter_collection === collection.id ? 'selected' : ''}}
|
||||
>
|
||||
{{collection.name}} ({{collection.asset_count}})
|
||||
</option>
|
||||
</for>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-asset-gallery__filter-group">
|
||||
<label class="admin-asset-gallery__filter-label">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
class="admin-asset-gallery__filter-input"
|
||||
data-live-action="search"
|
||||
data-live-debounce="300"
|
||||
placeholder="Search assets..."
|
||||
value="{{search_query}}"
|
||||
>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Gallery Area -->
|
||||
<div class="admin-asset-gallery__main">
|
||||
<!-- Bulk Actions Bar -->
|
||||
<if condition="selection_count > 0">
|
||||
<div class="admin-asset-gallery__bulk-actions">
|
||||
<span class="admin-asset-gallery__bulk-actions-text">
|
||||
{{selection_count}} asset(s) selected
|
||||
</span>
|
||||
<div class="admin-asset-gallery__bulk-actions-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--danger"
|
||||
data-live-action="deleteSelected"
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--secondary"
|
||||
data-live-action="clearSelection"
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<!-- Gallery Grid View -->
|
||||
<if condition="view_mode === 'grid'">
|
||||
<div class="admin-asset-gallery__grid">
|
||||
<for items="assets" as="asset">
|
||||
<div
|
||||
class="admin-asset-card {{selected_assets contains asset.id ? 'admin-asset-card--selected' : ''}}"
|
||||
data-asset-id="{{asset.id}}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="admin-asset-card__checkbox"
|
||||
data-live-action="toggleSelection"
|
||||
data-param-asset-id="{{asset.id}}"
|
||||
{{selected_assets contains asset.id ? 'checked' : ''}}
|
||||
>
|
||||
|
||||
<div class="admin-asset-card__preview">
|
||||
<if condition="asset.is_image">
|
||||
<img
|
||||
src="{{asset.url}}"
|
||||
alt="Asset Preview"
|
||||
class="admin-asset-card__preview-image"
|
||||
loading="lazy"
|
||||
>
|
||||
</if>
|
||||
<if condition="!asset.is_image">
|
||||
<div class="admin-asset-card__preview-icon">
|
||||
<span>{{asset.mime}}</span>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<div class="admin-asset-card__overlay">
|
||||
<div class="admin-asset-card__quick-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-asset-card__quick-action"
|
||||
onclick="window.open('{{asset.url}}', '_blank')"
|
||||
title="View Full Size"
|
||||
>
|
||||
<i class="icon-eye"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-asset-card__quick-action"
|
||||
data-live-action="deleteAsset"
|
||||
data-param-asset-id="{{asset.id}}"
|
||||
title="Delete Asset"
|
||||
>
|
||||
<i class="icon-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-asset-card__info">
|
||||
<if condition="asset.collections && count(asset.collections) > 0">
|
||||
<div class="admin-asset-card__collections">
|
||||
<for items="asset.collections" as="collection">
|
||||
<span class="admin-asset-card__collection-badge" title="{{collection.name}}">
|
||||
{{collection.name}}
|
||||
</span>
|
||||
</for>
|
||||
</div>
|
||||
</if>
|
||||
<div class="admin-asset-card__info-item">
|
||||
<span class="admin-asset-card__info-label">ID:</span>
|
||||
<span class="admin-asset-card__info-value">{{asset.id | substr(0, 8)}}...</span>
|
||||
</div>
|
||||
<div class="admin-asset-card__info-item">
|
||||
<span class="admin-asset-card__info-label">MIME:</span>
|
||||
<span class="admin-asset-card__info-value">{{asset.mime}}</span>
|
||||
</div>
|
||||
<if condition="asset.width && asset.height">
|
||||
<div class="admin-asset-card__info-item">
|
||||
<span class="admin-asset-card__info-label">Size:</span>
|
||||
<span class="admin-asset-card__info-value">{{asset.width}} × {{asset.height}}</span>
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
</div>
|
||||
</for>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<!-- Gallery List View -->
|
||||
<if condition="view_mode === 'list'">
|
||||
<table class="admin-asset-gallery__list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>ID</th>
|
||||
<th>Bucket</th>
|
||||
<th>MIME</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<for items="assets" as="asset">
|
||||
<tr>
|
||||
<td>
|
||||
<if condition="asset.is_image">
|
||||
<img
|
||||
src="{{asset.url}}"
|
||||
alt="Preview"
|
||||
class="admin-asset-gallery__list-preview-image"
|
||||
loading="lazy"
|
||||
>
|
||||
</if>
|
||||
<if condition="!asset.is_image">
|
||||
<span>{{asset.mime}}</span>
|
||||
</if>
|
||||
</td>
|
||||
<td>{{asset.id}}</td>
|
||||
<td>{{asset.bucket}}</td>
|
||||
<td>{{asset.mime}}</td>
|
||||
<td>{{asset.bytes | format_bytes}}</td>
|
||||
<td>{{asset.created_at | date('Y-m-d H:i')}}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--small admin-btn--danger"
|
||||
data-live-action="deleteAsset"
|
||||
data-param-asset-id="{{asset.id}}"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</for>
|
||||
</tbody>
|
||||
</table>
|
||||
</if>
|
||||
|
||||
<!-- Empty State -->
|
||||
<if condition="!has_assets">
|
||||
<div class="admin-asset-gallery__empty-state">
|
||||
<p class="admin-asset-gallery__empty-state-text">No assets found.</p>
|
||||
<a href="/admin/assets/create" class="admin-btn admin-btn--primary">
|
||||
Upload First Asset
|
||||
</a>
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More -->
|
||||
<if condition="has_more">
|
||||
<div class="admin-asset-gallery__load-more">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--secondary"
|
||||
data-live-action="loadMore"
|
||||
>
|
||||
Load More ({{total_assets - assets | count}} remaining)
|
||||
</button>
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
90
src/Framework/View/templates/admin-asset-metadata.view.php
Normal file
90
src/Framework/View/templates/admin-asset-metadata.view.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<div class="asset-metadata-editor">
|
||||
<div class="admin-alert admin-alert--error" if="{{error_message}}">
|
||||
{{error_message}}
|
||||
</div>
|
||||
|
||||
<div class="admin-alert admin-alert--success" if="{{!is_dirty}} && {{!error_message}}">
|
||||
Metadata saved successfully
|
||||
</div>
|
||||
|
||||
<div class="metadata-editor-header">
|
||||
<h3 class="metadata-editor-title">Edit Metadata</h3>
|
||||
<div class="metadata-editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--secondary admin-btn--sm"
|
||||
data-live-action="reset"
|
||||
disabled="{{is_loading}}"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary admin-btn--sm"
|
||||
data-live-action="save"
|
||||
disabled="{{is_loading}} || {{!is_dirty}}"
|
||||
>
|
||||
<span if="{{is_loading}}">Saving...</span>
|
||||
<span if="!{{is_loading}}">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata-editor-content">
|
||||
<textarea
|
||||
id="metadata-json"
|
||||
class="admin-textarea admin-textarea--code"
|
||||
rows="20"
|
||||
data-live-action="updateMetadataJson"
|
||||
data-live-debounce="500"
|
||||
placeholder='{"width": 1920, "height": 1080, "description": "..."}'
|
||||
>{{metadata_json}}</textarea>
|
||||
<small class="admin-form-help">Edit metadata as JSON. Changes are saved automatically when you click Save.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.asset-metadata-editor {
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.metadata-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.metadata-editor-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metadata-editor-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.metadata-editor-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-textarea--code {
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.admin-textarea--code:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px oklch(from var(--primary) l c h / 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
30
src/Framework/View/templates/admin-asset-slot-edit.view.php
Normal file
30
src/Framework/View/templates/admin-asset-slot-edit.view.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<div class="admin-asset-slot-edit">
|
||||
<div class="admin-header">
|
||||
<h1>Edit Asset Slot</h1>
|
||||
<div class="admin-actions">
|
||||
<a href="/admin/assets/asset-slots" class="btn btn-secondary">
|
||||
<i class="icon-arrow-left"></i> Back to Slots
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-container">
|
||||
{{ form }}
|
||||
|
||||
<if condition="current_asset_preview">
|
||||
<div class="current-asset-preview-section" style="margin-top: 2rem; padding: 1.5rem; background: #f8f9fa; border-radius: 8px;">
|
||||
<h3>Current Asset</h3>
|
||||
{{ current_asset_preview }}
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<if condition="current_asset">
|
||||
<div class="asset-picker-section" style="margin-top: 2rem;">
|
||||
<h3>Change Asset</h3>
|
||||
<p class="text-muted">Use the Asset ID field above to assign a different asset to this slot.</p>
|
||||
<p class="text-muted">Or use the Asset Picker below (if available):</p>
|
||||
<!-- Asset Picker LiveComponent will be integrated here -->
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
</div>
|
||||
14
src/Framework/View/templates/admin-asset-slots.view.php
Normal file
14
src/Framework/View/templates/admin-asset-slots.view.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="admin-asset-slots">
|
||||
<div class="admin-header">
|
||||
<h1>Asset Slots</h1>
|
||||
<div class="admin-actions">
|
||||
<a href="/admin/assets/asset-slots/create" class="btn btn-primary">
|
||||
<i class="icon-plus"></i> Create Slot
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-container">
|
||||
{{ table }}
|
||||
</div>
|
||||
</div>
|
||||
158
src/Framework/View/templates/admin-asset-tags.view.php
Normal file
158
src/Framework/View/templates/admin-asset-tags.view.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<div class="asset-tags-manager">
|
||||
<div class="admin-alert admin-alert--error" if="{{error_message}}">
|
||||
{{error_message}}
|
||||
</div>
|
||||
|
||||
<div class="asset-tags-current">
|
||||
<h3 class="asset-tags-title">Current Tags</h3>
|
||||
<div class="asset-tags-list">
|
||||
<span class="admin-badge admin-badge--info asset-tag" foreach="{{tags as tag}}">
|
||||
{{tag}}
|
||||
<button
|
||||
type="button"
|
||||
class="tag-remove-btn"
|
||||
data-live-action="removeTag"
|
||||
data-live-params='{"tag": "{{tag}}"}'
|
||||
title="Remove tag"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<span class="asset-tags-empty" if="!{{tags}}">No tags assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="asset-tags-add">
|
||||
<h3 class="asset-tags-title">Add Tag</h3>
|
||||
<div class="asset-tags-input-group">
|
||||
<input
|
||||
type="text"
|
||||
class="admin-input asset-tags-input"
|
||||
placeholder="Enter tag name..."
|
||||
value="{{new_tag_input}}"
|
||||
data-live-model="newTagInput"
|
||||
data-live-debounce="300"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary admin-btn--sm"
|
||||
data-live-action="addTag"
|
||||
disabled="{{is_loading}}"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="asset-tags-suggestions" if="{{suggestions}}">
|
||||
<small class="asset-tags-suggestions-label">Suggestions:</small>
|
||||
<div class="asset-tags-suggestions-list">
|
||||
<button
|
||||
type="button"
|
||||
class="tag-suggestion"
|
||||
foreach="{{suggestions as suggestion}}"
|
||||
data-live-action="addTag"
|
||||
data-live-param-tag="{{suggestion}}"
|
||||
>
|
||||
{{suggestion}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.asset-tags-manager {
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.asset-tags-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.asset-tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.asset-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.tag-remove-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-inverse);
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(--space-xs);
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--duration-default) var(--easing-default);
|
||||
}
|
||||
|
||||
.tag-remove-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.asset-tags-empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.asset-tags-input-group {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.asset-tags-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asset-tags-suggestions {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.asset-tags-suggestions-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.asset-tags-suggestions-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.tag-suggestion {
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-default) var(--easing-default);
|
||||
}
|
||||
|
||||
.tag-suggestion:hover {
|
||||
background: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
207
src/Framework/View/templates/admin-asset-upload.view.php
Normal file
207
src/Framework/View/templates/admin-asset-upload.view.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<div class="admin-asset-upload">
|
||||
<!-- Error Display -->
|
||||
<div class="admin-alert admin-alert--error" if="{{error_message}}">
|
||||
<strong>Error:</strong> {{error_message}}
|
||||
</div>
|
||||
|
||||
<!-- Success Message (Single Upload) -->
|
||||
<div class="admin-alert admin-alert--success" if="{{upload_status}} === 'success' && !batch_mode">
|
||||
<strong>Success!</strong> Asset uploaded successfully.
|
||||
<span if="{{asset_id}}">Asset ID: {{asset_id}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Batch Mode Toggle -->
|
||||
<div class="admin-asset-upload__mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-asset-upload__mode-button {{!batch_mode ? 'admin-asset-upload__mode-button--active' : ''}}"
|
||||
data-live-action="clearUpload"
|
||||
>
|
||||
Single Upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-asset-upload__mode-button {{batch_mode ? 'admin-asset-upload__mode-button--active' : ''}}"
|
||||
data-live-action="enableBatchMode"
|
||||
>
|
||||
Batch Upload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Zone -->
|
||||
<div class="admin-upload-zone" data-live-dropzone if="{{!has_active_uploads}}">
|
||||
<div class="admin-upload-zone__icon">📁</div>
|
||||
<p class="admin-upload-zone__text">
|
||||
{{batch_mode ? 'Drag & drop files here (multiple files supported)' : 'Drag & drop file here'}}
|
||||
</p>
|
||||
<p class="admin-upload-zone__subtext">or</p>
|
||||
<label class="admin-upload-zone__button">
|
||||
{{batch_mode ? 'Choose Files' : 'Choose File'}}
|
||||
<input
|
||||
type="file"
|
||||
data-live-upload
|
||||
{{batch_mode ? 'multiple' : ''}}
|
||||
accept="image/*,video/*,.pdf,.doc,.docx"
|
||||
class="admin-upload-zone__input"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-upload-zone__info">
|
||||
Max size: 100MB per file | Formats: Images, Videos, PDFs, Documents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Batch Upload Progress -->
|
||||
<if condition="batch_mode && upload_queue && count(upload_queue) > 0">
|
||||
<div class="admin-asset-upload__batch-progress">
|
||||
<div class="admin-asset-upload__batch-progress-header">
|
||||
<h3 class="admin-asset-upload__batch-progress-title">
|
||||
Upload Queue ({{count(upload_queue)}} files)
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--secondary admin-btn--sm"
|
||||
data-live-action="clearQueue"
|
||||
>
|
||||
Clear Queue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-asset-upload__queue">
|
||||
<for items="upload_queue" as="item">
|
||||
<div class="admin-upload-item admin-upload-item--{{item.status}}">
|
||||
<div class="admin-upload-item__preview">
|
||||
<if condition="item.preview_url">
|
||||
<img src="{{item.preview_url}}" alt="{{item.filename}}" class="admin-upload-item__preview-image">
|
||||
</if>
|
||||
<if condition="!item.preview_url">
|
||||
<div class="admin-upload-item__preview-icon">
|
||||
{{item.mime_type | substr(0, 1) | upper}}
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
|
||||
<div class="admin-upload-item__info">
|
||||
<div class="admin-upload-item__filename">{{item.filename}}</div>
|
||||
<div class="admin-upload-item__meta">
|
||||
<span class="admin-upload-item__size">{{item.size | format_filesize}}</span>
|
||||
<span class="admin-upload-item__mime">{{item.mime_type}}</span>
|
||||
</div>
|
||||
|
||||
<if condition="item.status === 'uploading' || item.status === 'pending'">
|
||||
<div class="admin-upload-item__progress">
|
||||
<div class="admin-progress-bar admin-progress-bar--sm">
|
||||
<div class="admin-progress-fill" style="width: {{item.progress}}%"></div>
|
||||
</div>
|
||||
<span class="admin-progress-text">{{item.progress}}%</span>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<if condition="item.status === 'success'">
|
||||
<div class="admin-upload-item__success">
|
||||
<i class="icon-check"></i> Uploaded successfully
|
||||
<if condition="item.asset_id">
|
||||
<span class="admin-upload-item__asset-id">ID: {{item.asset_id}}</span>
|
||||
</if>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<if condition="item.status === 'error'">
|
||||
<div class="admin-upload-item__error">
|
||||
<i class="icon-error"></i> {{item.error_message}}
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
|
||||
<div class="admin-upload-item__actions">
|
||||
<if condition="item.status === 'pending' || item.status === 'error'">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--danger admin-btn--sm"
|
||||
data-live-action="removeFromQueue"
|
||||
data-param-item-id="{{item.id}}"
|
||||
title="Remove from queue"
|
||||
>
|
||||
<i class="icon-trash"></i>
|
||||
</button>
|
||||
</if>
|
||||
</div>
|
||||
</div>
|
||||
</for>
|
||||
</div>
|
||||
|
||||
<if condition="has_active_uploads">
|
||||
<div class="admin-asset-upload__overall-progress">
|
||||
<div class="admin-progress-bar">
|
||||
<div class="admin-progress-fill" style="width: {{upload_progress}}%"></div>
|
||||
</div>
|
||||
<span class="admin-progress-text">Overall Progress: {{upload_progress}}%</span>
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<!-- Single Upload Progress -->
|
||||
<if condition="!batch_mode && upload_status === 'uploading'">
|
||||
<div class="admin-upload-progress">
|
||||
<div class="admin-progress-bar">
|
||||
<div class="admin-progress-fill" style="width: {{upload_progress}}%"></div>
|
||||
</div>
|
||||
<span class="admin-progress-text">{{upload_progress}}%</span>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<!-- Single Upload Preview -->
|
||||
<if condition="!batch_mode && preview_url">
|
||||
<div class="admin-file-preview">
|
||||
<img src="{{preview_url}}" alt="Preview" class="admin-file-preview__image">
|
||||
<div class="admin-file-preview__info">
|
||||
<p><strong>File:</strong> {{uploaded_file}}</p>
|
||||
<button
|
||||
data-live-action="clearUpload"
|
||||
class="admin-btn admin-btn--secondary admin-btn--sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="admin-asset-upload__form">
|
||||
<div class="admin-form-grid">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="bucket">
|
||||
Bucket
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bucket"
|
||||
name="bucket"
|
||||
class="admin-input"
|
||||
value="{{bucket}}"
|
||||
data-live-action="updateBucket"
|
||||
data-live-debounce="500"
|
||||
placeholder="media"
|
||||
>
|
||||
<small class="admin-form-help">Optional bucket name (default: media)</small>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="tags">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
name="tags"
|
||||
class="admin-input"
|
||||
value="{{tags_string}}"
|
||||
data-live-action="updateTags"
|
||||
data-live-debounce="500"
|
||||
placeholder="tag1,tag2,tag3"
|
||||
>
|
||||
<small class="admin-form-help">Comma-separated list of tags</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
116
src/Framework/View/templates/admin-asset-variants.view.php
Normal file
116
src/Framework/View/templates/admin-asset-variants.view.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<div class="admin-asset-variants">
|
||||
<!-- Error Display -->
|
||||
<div class="admin-alert admin-alert--error" if="{{error_message}}">
|
||||
<strong>Error:</strong> {{error_message}}
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="admin-asset-variants__header">
|
||||
<h2 class="admin-asset-variants__title">Variants</h2>
|
||||
<div class="admin-asset-variants__actions">
|
||||
<if condition="is_image">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary admin-btn--sm"
|
||||
data-live-action="generateVariants"
|
||||
{{is_generating ? 'disabled' : ''}}
|
||||
>
|
||||
<if condition="is_generating">
|
||||
<i class="icon-loading"></i> Generating...
|
||||
</if>
|
||||
<if condition="!is_generating">
|
||||
<i class="icon-plus"></i> Generate Variants
|
||||
</if>
|
||||
</button>
|
||||
</if>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--secondary admin-btn--sm"
|
||||
data-live-action="refresh"
|
||||
>
|
||||
<i class="icon-refresh"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variants Grid -->
|
||||
<if condition="has_variants">
|
||||
<div class="admin-asset-variants__grid">
|
||||
<for items="variants" as="variant">
|
||||
<div class="admin-variant-card">
|
||||
<div class="admin-variant-card__preview">
|
||||
<if condition="variant.mime | str_starts_with('image/')">
|
||||
<img
|
||||
src="{{variant.url}}"
|
||||
alt="{{variant.variant}}"
|
||||
class="admin-variant-card__thumbnail"
|
||||
loading="lazy"
|
||||
>
|
||||
</if>
|
||||
<if condition="!variant.mime | str_starts_with('image/')">
|
||||
<div class="admin-variant-card__icon">
|
||||
<span class="admin-variant-card__icon-text">{{variant.mime | substr(0, 1) | upper}}</span>
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
|
||||
<div class="admin-variant-card__info">
|
||||
<div class="admin-variant-card__name">{{variant.variant}}</div>
|
||||
<div class="admin-variant-card__meta">
|
||||
<if condition="variant.width && variant.height">
|
||||
<span class="admin-variant-card__dimensions">
|
||||
{{variant.width}} × {{variant.height}}
|
||||
</span>
|
||||
</if>
|
||||
<span class="admin-variant-card__size">{{variant.bytes | format_filesize}}</span>
|
||||
<span class="admin-variant-card__format">{{variant.mime}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-variant-card__actions">
|
||||
<a
|
||||
href="{{variant.url}}"
|
||||
target="_blank"
|
||||
class="admin-btn admin-btn--secondary admin-btn--sm"
|
||||
title="Open in new tab"
|
||||
>
|
||||
<i class="icon-external"></i>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--danger admin-btn--sm"
|
||||
data-live-action="deleteVariant"
|
||||
data-param-variant-name="{{variant.variant}}"
|
||||
title="Delete variant"
|
||||
>
|
||||
<i class="icon-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</for>
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<!-- Empty State -->
|
||||
<if condition="!has_variants">
|
||||
<div class="admin-asset-variants__empty">
|
||||
<div class="admin-asset-variants__empty-icon">📦</div>
|
||||
<p class="admin-asset-variants__empty-text">No variants created yet.</p>
|
||||
<if condition="is_image">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary"
|
||||
data-live-action="generateVariants"
|
||||
>
|
||||
<i class="icon-plus"></i> Generate Variants
|
||||
</button>
|
||||
</if>
|
||||
<if condition="!is_image">
|
||||
<p class="admin-asset-variants__empty-hint">
|
||||
Variants can only be generated for images.
|
||||
</p>
|
||||
</if>
|
||||
</div>
|
||||
</if>
|
||||
</div>
|
||||
|
||||
338
src/Framework/View/templates/admin-block-editor.view.php
Normal file
338
src/Framework/View/templates/admin-block-editor.view.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<div class="admin-block-editor">
|
||||
<!-- Block Type Selection -->
|
||||
<div class="admin-block-editor__toolbar">
|
||||
<h3 class="admin-block-editor__title">Add Block</h3>
|
||||
<div class="admin-block-editor__block-types">
|
||||
<button
|
||||
for="{{available_block_types}}"
|
||||
type="button"
|
||||
class="admin-block-type-btn"
|
||||
data-live-action="addBlock"
|
||||
data-param-block-type="{{$key}}"
|
||||
>
|
||||
<span class="admin-block-type-btn__icon">{{$key}}</span>
|
||||
<span class="admin-block-type-btn__label">{{$value}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Selection -->
|
||||
<div if="{{!empty(available_templates)}}" class="admin-block-editor__templates">
|
||||
<h3 class="admin-block-editor__title">Apply Template</h3>
|
||||
<p class="admin-form-help">Select a template to replace all current blocks</p>
|
||||
<div class="admin-block-editor__template-select">
|
||||
<select
|
||||
class="admin-select"
|
||||
data-live-action="applyTemplate"
|
||||
data-param-template-id-from="value"
|
||||
onchange="this.form.dispatchEvent(new Event('change'))"
|
||||
>
|
||||
<option value="">-- Select Template --</option>
|
||||
<option for="{{available_templates}}" value="{{$key}}">
|
||||
{{$value.name}} - {{$value.description}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocks List -->
|
||||
<div class="admin-block-editor__blocks" data-sortable="blocks">
|
||||
<div for="{{blocks}}" class="admin-block-card" data-block-id="{{id}}">
|
||||
<div class="admin-block-card__header">
|
||||
<div class="admin-block-card__drag-handle" title="Drag to reorder" data-drag-handle="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 2h2v2H6V2zM6 7h2v2H6V7zM6 12h2v2H6v-2zM8 2h2v2h-2V2zM8 7h2v2h-2V7zM8 12h2v2h-2v-2z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="admin-block-card__type">
|
||||
<span class="admin-block-type-icon admin-block-type-icon--{{type}}"></span>
|
||||
{{type}}
|
||||
</span>
|
||||
<div class="admin-block-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--small admin-btn--secondary"
|
||||
data-live-action="editBlock"
|
||||
data-param-block-id="{{id}}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--small admin-btn--danger"
|
||||
data-live-action="removeBlock"
|
||||
data-param-block-id="{{id}}"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-block-card__preview">
|
||||
<!-- Block preview will be shown here -->
|
||||
<span class="admin-block-card__preview-text">{{type}} Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div if="{{empty(blocks)}}" class="admin-block-editor__empty">
|
||||
<p>No blocks yet. Add a block to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Edit Form (shown when editing) -->
|
||||
<div if="{{editing_block}}" class="admin-block-editor__edit-form">
|
||||
<h3 class="admin-block-editor__title">Edit Block: {{editing_block.type}}</h3>
|
||||
|
||||
<!-- Hero Block Form -->
|
||||
<div if="{{editing_block.type === 'hero'}}" class="admin-block-form">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="hero-title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="hero-title"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.title}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="title"
|
||||
data-live-debounce="300"
|
||||
>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="hero-subtitle">Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
id="hero-subtitle"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.subtitle}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="subtitle"
|
||||
data-live-debounce="300"
|
||||
>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="hero-background-image">Background Image</label>
|
||||
<div class="admin-asset-picker-wrapper">
|
||||
<!-- Asset Picker Component -->
|
||||
<div class="admin-asset-picker-container">
|
||||
{{{asset_picker_html}}}
|
||||
</div>
|
||||
<!-- Current value display -->
|
||||
<div if="{{editing_block.data.backgroundImage}}" class="admin-asset-preview">
|
||||
<span class="admin-asset-preview__label">Current:</span>
|
||||
<span class="admin-asset-preview__value">{{editing_block.data.backgroundImage}}</span>
|
||||
</div>
|
||||
<!-- Manual input fallback -->
|
||||
<input
|
||||
type="text"
|
||||
id="hero-background-image"
|
||||
name="value"
|
||||
class="admin-input admin-input--small"
|
||||
value="{{editing_block.data.backgroundImage}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="backgroundImage"
|
||||
placeholder="Asset ID or URL"
|
||||
>
|
||||
<small class="admin-form-help">Select an asset using the picker above, or enter an Asset ID or URL manually</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="hero-cta-text">CTA Text</label>
|
||||
<input
|
||||
type="text"
|
||||
id="hero-cta-text"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.ctaText}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="ctaText"
|
||||
>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="hero-cta-link">CTA Link</label>
|
||||
<input
|
||||
type="text"
|
||||
id="hero-cta-link"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.ctaLink}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="ctaLink"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Block Form -->
|
||||
<div if="{{editing_block.type === 'text'}}" class="admin-block-form">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="text-content">Content</label>
|
||||
<textarea
|
||||
id="text-content"
|
||||
name="value"
|
||||
class="admin-textarea"
|
||||
rows="8"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="content"
|
||||
data-live-debounce="500"
|
||||
>{{editing_block.data.content}}</textarea>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="text-alignment">Alignment</label>
|
||||
<select
|
||||
id="text-alignment"
|
||||
name="value"
|
||||
class="admin-select"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="alignment"
|
||||
>
|
||||
<option value="left" selected="{{editing_block.data.alignment === 'left'}}">Left</option>
|
||||
<option value="center" selected="{{editing_block.data.alignment === 'center'}}">Center</option>
|
||||
<option value="right" selected="{{editing_block.data.alignment === 'right'}}">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="text-max-width">Max Width</label>
|
||||
<input
|
||||
type="text"
|
||||
id="text-max-width"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.maxWidth}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="maxWidth"
|
||||
placeholder="100%"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Block Form -->
|
||||
<div if="{{editing_block.type === 'image'}}" class="admin-block-form">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="image-image-id">Image</label>
|
||||
<div class="admin-asset-picker-wrapper">
|
||||
<!-- Asset Picker Component -->
|
||||
<div class="admin-asset-picker-container">
|
||||
{{{asset_picker_html}}}
|
||||
</div>
|
||||
<!-- Current value display -->
|
||||
<div if="{{editing_block.data.imageId}}" class="admin-asset-preview">
|
||||
<span class="admin-asset-preview__label">Current:</span>
|
||||
<span class="admin-asset-preview__value">{{editing_block.data.imageId}}</span>
|
||||
</div>
|
||||
<!-- Manual input fallback -->
|
||||
<input
|
||||
type="text"
|
||||
id="image-image-id"
|
||||
name="value"
|
||||
class="admin-input admin-input--small"
|
||||
value="{{editing_block.data.imageId}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="imageId"
|
||||
placeholder="Asset ID"
|
||||
>
|
||||
<small class="admin-form-help">Select an asset using the picker above, or enter an Asset ID manually</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="image-url">Image URL (fallback)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="image-url"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.imageUrl}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="imageUrl"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="image-alt">Alt Text</label>
|
||||
<input
|
||||
type="text"
|
||||
id="image-alt"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.alt}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="alt"
|
||||
>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="image-caption">Caption</label>
|
||||
<input
|
||||
type="text"
|
||||
id="image-caption"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{editing_block.data.caption}}"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="caption"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Block Form (for other types) -->
|
||||
<div if="{{editing_block.type !== 'hero' && editing_block.type !== 'text' && editing_block.type !== 'image'}}" class="admin-block-form">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label">Block Data (JSON)</label>
|
||||
<textarea
|
||||
name="value"
|
||||
class="admin-textarea admin-textarea--code"
|
||||
rows="10"
|
||||
data-live-action="updateBlockField"
|
||||
data-param-block-id="{{editing_block.id}}"
|
||||
data-param-field-path="__json"
|
||||
>{{editing_block.data | json_encode}}</textarea>
|
||||
<small class="admin-form-help">Edit block data as JSON</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-block-form__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--secondary"
|
||||
data-live-action="stopEditing"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Toggle -->
|
||||
<div class="admin-block-editor__preview-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-block-editor__preview-toggle {{is_preview_mode ? 'admin-block-editor__preview-toggle--active' : ''}}"
|
||||
data-live-action="togglePreview"
|
||||
>
|
||||
<span if="{{is_preview_mode}}">Hide Preview</span>
|
||||
<span if="!{{is_preview_mode}}">Show Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview Area (inline) -->
|
||||
<div if="{{is_preview_mode}}" class="admin-block-editor__preview">
|
||||
<h3 class="admin-block-editor__title">Live Preview</h3>
|
||||
<div class="admin-block-editor__preview-content">
|
||||
<div if="{{preview_html}}">
|
||||
{{{preview_html}}}
|
||||
</div>
|
||||
<div if="!{{preview_html}}">
|
||||
<p class="admin-block-editor__preview-loading">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
216
src/Framework/View/templates/admin-content-form.view.php
Normal file
216
src/Framework/View/templates/admin-content-form.view.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<div class="admin-content-form">
|
||||
<!-- Auto-Save Indicator -->
|
||||
<div class="admin-content-form__auto-save" id="auto-save-indicator">
|
||||
<span class="admin-content-form__auto-save-icon">💾</span>
|
||||
<span class="admin-content-form__auto-save-text">Draft saved</span>
|
||||
</div>
|
||||
|
||||
<!-- Validation Errors -->
|
||||
<div class="admin-content-form__alert admin-content-form__alert--error" if="{{has_errors}}">
|
||||
<strong>Please fix the following errors:</strong>
|
||||
<ul class="admin-content-form__error-list">
|
||||
<li class="admin-content-form__error-item" foreach="$validation_error_list as $error">
|
||||
{{$error.key}}: {{$error.value}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Draft Save Indicator -->
|
||||
<div class="admin-content-form__alert admin-content-form__alert--info" if="{{is_draft}} && {{last_saved}}">
|
||||
<strong>Draft saved</strong> at {{last_saved}}
|
||||
</div>
|
||||
|
||||
<!-- Form Sections -->
|
||||
<div class="admin-content-form__sections">
|
||||
<!-- Basic Info Section -->
|
||||
<div class="admin-content-form__section">
|
||||
<div class="admin-content-form__section-header" data-section-toggle="basic-info">
|
||||
<h3 class="admin-content-form__section-title">
|
||||
<span>Basic Information</span>
|
||||
</h3>
|
||||
<button type="button" class="admin-content-form__section-toggle" aria-label="Toggle section">
|
||||
<svg class="admin-content-form__section-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-content-form__section-content" id="basic-info-content">
|
||||
<div class="admin-content-form__grid">
|
||||
<div class="admin-content-form__field admin-content-form__field--full">
|
||||
<label class="admin-content-form__label" for="content_type_id">
|
||||
Content Type
|
||||
<span class="admin-content-form__required">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="content_type_id"
|
||||
name="content_type_id"
|
||||
class="admin-select {{validation_errors.content_type_id ? 'admin-input--error' : ''}}"
|
||||
data-live-action="updateContentType"
|
||||
value="{{content_type_id}}"
|
||||
>
|
||||
<option value="" selected="{{content_type_id === null}}">-- Select Content Type --</option>
|
||||
<option foreach="$content_type_options as $option" value="{{$option.id}}" selected="{{content_type_id !== null && content_type_id === $option.id}}">
|
||||
{{$option.name}}
|
||||
</option>
|
||||
</select>
|
||||
<small class="admin-content-form__error" if="{{validation_errors.content_type_id}}">
|
||||
{{validation_errors.content_type_id}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="admin-content-form__field admin-content-form__field--full">
|
||||
<label class="admin-content-form__label" for="title">
|
||||
Title
|
||||
<span class="admin-content-form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
class="admin-input {{validation_errors.title ? 'admin-input--error' : ''}}"
|
||||
value="{{title}}"
|
||||
data-live-action="updateTitle"
|
||||
data-live-debounce="300"
|
||||
placeholder="Enter content title"
|
||||
>
|
||||
<small class="admin-content-form__error" if="{{validation_errors.title}}">
|
||||
{{validation_errors.title}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="admin-content-form__field admin-content-form__field--full">
|
||||
<label class="admin-content-form__label" for="slug">
|
||||
Slug
|
||||
<span class="admin-content-form__badge admin-content-form__badge--info" if="{{slug_auto_generated}}">
|
||||
Auto-generated
|
||||
</span>
|
||||
</label>
|
||||
<div class="admin-content-form__input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
class="admin-input {{validation_errors.slug ? 'admin-input--error' : ''}}"
|
||||
value="{{slug}}"
|
||||
data-live-action="updateSlug"
|
||||
data-live-debounce="500"
|
||||
placeholder="url-friendly-slug"
|
||||
>
|
||||
<small class="admin-content-form__help">
|
||||
URL-friendly identifier (auto-generated from title if empty)
|
||||
</small>
|
||||
</div>
|
||||
<small class="admin-content-form__error" if="{{validation_errors.slug}}">
|
||||
{{validation_errors.slug}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Blocks Section -->
|
||||
<div class="admin-content-form__section">
|
||||
<div class="admin-content-form__section-header" data-section-toggle="content-blocks">
|
||||
<h3 class="admin-content-form__section-title">
|
||||
<span>Content Blocks</span>
|
||||
</h3>
|
||||
<button type="button" class="admin-content-form__section-toggle" aria-label="Toggle section">
|
||||
<svg class="admin-content-form__section-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-content-form__section-content" id="content-blocks-content">
|
||||
<div class="admin-content-form__field admin-content-form__field--full">
|
||||
{{{block_editor_html}}}
|
||||
<small class="admin-content-form__error" if="{{validation_errors.blocks_json}}">
|
||||
{{validation_errors.blocks_json}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
<div class="admin-content-form__section">
|
||||
<div class="admin-content-form__section-header" data-section-toggle="metadata">
|
||||
<h3 class="admin-content-form__section-title">
|
||||
<span>SEO & Meta Data</span>
|
||||
</h3>
|
||||
<button type="button" class="admin-content-form__section-toggle" aria-label="Toggle section">
|
||||
<svg class="admin-content-form__section-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-content-form__section-content" id="metadata-content">
|
||||
<div class="admin-content-form__field admin-content-form__field--full">
|
||||
{{{metadata_form_html}}}
|
||||
<small class="admin-content-form__error" if="{{validation_errors.meta_data_json}}">
|
||||
{{validation_errors.meta_data_json}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="admin-content-form__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-content-form__actions-button admin-content-form__actions-button--secondary"
|
||||
data-live-action="validateForm"
|
||||
>
|
||||
Validate Form
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-content-form__actions-button admin-content-form__actions-button--secondary"
|
||||
data-live-action="autoSave"
|
||||
>
|
||||
Save Draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-content-form__actions-button admin-content-form__actions-button--primary"
|
||||
data-live-action="submit"
|
||||
>
|
||||
<span if="{{is_edit_mode}}">Update Content</span>
|
||||
<span if="!{{is_edit_mode}}">Create Content</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts Help Button -->
|
||||
<div class="admin-content-form__shortcuts">
|
||||
<button type="button" class="admin-content-form__shortcuts-button" aria-label="Show keyboard shortcuts">
|
||||
⌨️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Collapsible sections
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-section-toggle]').forEach(header => {
|
||||
header.addEventListener('click', function() {
|
||||
const section = this.closest('.admin-content-form__section');
|
||||
const content = section.querySelector('.admin-content-form__section-content');
|
||||
section.classList.toggle('admin-content-form__section--collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-save indicator
|
||||
const autoSaveIndicator = document.getElementById('auto-save-indicator');
|
||||
if (autoSaveIndicator) {
|
||||
// Show indicator when auto-save happens (triggered by LiveComponent events)
|
||||
document.addEventListener('livecomponent:event', function(e) {
|
||||
if (e.detail.event === 'draft:saved') {
|
||||
autoSaveIndicator.classList.add('admin-content-form__auto-save--visible');
|
||||
setTimeout(() => {
|
||||
autoSaveIndicator.classList.remove('admin-content-form__auto-save--visible');
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
267
src/Framework/View/templates/admin-metadata-form.view.php
Normal file
267
src/Framework/View/templates/admin-metadata-form.view.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<div class="admin-metadata-form">
|
||||
<!-- Validation Errors -->
|
||||
<div class="admin-alert admin-alert--error" if="{{has_errors}}">
|
||||
<strong>Please fix the following errors:</strong>
|
||||
<ul class="admin-error-list">
|
||||
<li for="{{validation_errors}}">{{$key}}: {{$value}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- SEO Section -->
|
||||
<div class="admin-form-section">
|
||||
<h3 class="admin-form-section__title">SEO Metadata</h3>
|
||||
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="metadata-title">Meta Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="metadata-title"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{title}}"
|
||||
data-live-action="updateTitle"
|
||||
data-param-title-from="value"
|
||||
data-live-debounce="300"
|
||||
placeholder="Page title for search engines"
|
||||
maxlength="60"
|
||||
>
|
||||
<small class="admin-form-help">Recommended: 50-60 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="metadata-description">Meta Description</label>
|
||||
<textarea
|
||||
id="metadata-description"
|
||||
name="value"
|
||||
class="admin-textarea"
|
||||
rows="3"
|
||||
data-live-action="updateDescription"
|
||||
data-param-description-from="value"
|
||||
data-live-debounce="300"
|
||||
placeholder="Brief description for search engines"
|
||||
maxlength="160"
|
||||
>{{description}}</textarea>
|
||||
<small class="admin-form-help">Recommended: 150-160 characters</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open Graph Section -->
|
||||
<div class="admin-form-section">
|
||||
<h3 class="admin-form-section__title">Open Graph Metadata</h3>
|
||||
<p class="admin-form-help">These fields are used when sharing on social media platforms.</p>
|
||||
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="metadata-og-title">OG Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="metadata-og-title"
|
||||
name="value"
|
||||
class="admin-input"
|
||||
value="{{og_title}}"
|
||||
data-live-action="updateOgTitle"
|
||||
data-param-og-title-from="value"
|
||||
data-live-debounce="300"
|
||||
placeholder="Title for social media shares (optional)"
|
||||
>
|
||||
<small class="admin-form-help">If empty, Meta Title will be used</small>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="metadata-og-description">OG Description</label>
|
||||
<textarea
|
||||
id="metadata-og-description"
|
||||
name="value"
|
||||
class="admin-textarea"
|
||||
rows="3"
|
||||
data-live-action="updateOgDescription"
|
||||
data-param-og-description-from="value"
|
||||
data-live-debounce="300"
|
||||
placeholder="Description for social media shares (optional)"
|
||||
>{{og_description}}</textarea>
|
||||
<small class="admin-form-help">If empty, Meta Description will be used</small>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-form-label" for="metadata-og-image">OG Image</label>
|
||||
<div class="admin-asset-picker-wrapper">
|
||||
<!-- Asset Picker Component -->
|
||||
<div class="admin-asset-picker-container">
|
||||
{{{asset_picker_html}}}
|
||||
</div>
|
||||
<!-- Current value display -->
|
||||
<div if="{{og_image_id}}" class="admin-asset-preview">
|
||||
<span class="admin-asset-preview__label">Current:</span>
|
||||
<span class="admin-asset-preview__value">{{og_image_id}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--small admin-btn--secondary"
|
||||
data-live-action="clearOgImage"
|
||||
style="margin-left: auto;"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<!-- Manual input fallback -->
|
||||
<input
|
||||
type="text"
|
||||
id="metadata-og-image"
|
||||
name="value"
|
||||
class="admin-input admin-input--small"
|
||||
value="{{og_image_id}}"
|
||||
placeholder="Asset ID or URL"
|
||||
style="margin-top: 0.5rem;"
|
||||
readonly
|
||||
>
|
||||
<small class="admin-form-help">Select an image using the picker above. Recommended: 1200x630px</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-metadata-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-form-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border, #e0e0e0);
|
||||
}
|
||||
|
||||
.admin-form-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-form-section__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text, #333);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.admin-form-field {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-form-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text, #333);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-input,
|
||||
.admin-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.admin-input:focus,
|
||||
.admin-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #2196F3);
|
||||
}
|
||||
|
||||
.admin-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.admin-form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
|
||||
.admin-alert {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-alert--error {
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
.admin-error-list {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-asset-picker-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-asset-picker-container {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-asset-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-asset-preview__label {
|
||||
font-weight: 600;
|
||||
color: var(--text, #333);
|
||||
}
|
||||
|
||||
.admin-asset-preview__value {
|
||||
color: var(--muted, #666);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.admin-btn--small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-btn--secondary {
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
color: var(--text, #333);
|
||||
}
|
||||
|
||||
.admin-btn--secondary:hover {
|
||||
background: var(--bg-hover, #e0e0e0);
|
||||
}
|
||||
|
||||
.admin-input--small {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
169
src/Framework/View/templates/admin-ssl-dashboard.view.php
Normal file
169
src/Framework/View/templates/admin-ssl-dashboard.view.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<layout src="admin"/>
|
||||
|
||||
<div class="admin-ssl-dashboard">
|
||||
<div class="admin-header">
|
||||
<h1>SSL Certificate Management</h1>
|
||||
<div class="admin-actions">
|
||||
<button class="btn btn-primary" id="test-ssl-config">
|
||||
<i class="icon-check"></i> Test Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ssl-status-section" style="margin-top: 2rem;">
|
||||
<h2>Certificate Status</h2>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-success" if="{{certificate_exists}}">
|
||||
<strong>Certificate exists</strong>
|
||||
</div>
|
||||
<div class="alert alert-warning" if="{{!certificate_exists}}">
|
||||
<strong>No certificate found</strong>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" if="{{needs_renewal}}">
|
||||
<strong>Certificate needs renewal</strong>
|
||||
</div>
|
||||
|
||||
<div class="ssl-info">
|
||||
<h3>Configuration</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<td>{{ config.domain }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Mode</th>
|
||||
<td>{{ config.mode }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Certbot Config Directory</th>
|
||||
<td>{{ config.certbot_conf_dir }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="ssl-status-details" style="margin-top: 2rem;">
|
||||
<h3>Status Details</h3>
|
||||
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto;">{{ status | json_encode }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ssl-actions-section" style="margin-top: 2rem;">
|
||||
<h2>Actions</h2>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="action-buttons" style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||
<button class="btn btn-primary" id="obtain-certificate" if="{{!certificate_exists}}">
|
||||
<i class="icon-plus"></i> Obtain Certificate
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="renew-certificate" if="{{certificate_exists}}">
|
||||
<i class="icon-refresh"></i> Renew Certificate
|
||||
</button>
|
||||
<button class="btn btn-danger" id="revoke-certificate" if="{{certificate_exists}}">
|
||||
<i class="icon-trash"></i> Revoke Certificate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const testBtn = document.getElementById('test-ssl-config');
|
||||
const obtainBtn = document.getElementById('obtain-certificate');
|
||||
const renewBtn = document.getElementById('renew-certificate');
|
||||
const revokeBtn = document.getElementById('revoke-certificate');
|
||||
|
||||
// Helper function to make API requests
|
||||
function makeRequest(url, options = {}) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (testBtn) {
|
||||
testBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
const response = await makeRequest('/api/admin/ssl/test', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message);
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (obtainBtn) {
|
||||
obtainBtn.addEventListener('click', async function() {
|
||||
if (!confirm('Are you sure you want to obtain a new SSL certificate?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await makeRequest('/api/admin/ssl/obtain', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message);
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (renewBtn) {
|
||||
renewBtn.addEventListener('click', async function() {
|
||||
if (!confirm('Are you sure you want to renew the SSL certificate?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await makeRequest('/api/admin/ssl/renew', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message);
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (revokeBtn) {
|
||||
revokeBtn.addEventListener('click', async function() {
|
||||
if (!confirm('Are you sure you want to revoke the SSL certificate? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await makeRequest('/api/admin/ssl/revoke', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message);
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
3
src/Framework/View/templates/cms-content.view.php
Normal file
3
src/Framework/View/templates/cms-content.view.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<layout src="main">
|
||||
{{{bodyContent}}}
|
||||
</layout>
|
||||
9
src/Framework/View/templates/components/cms/hero.html
Normal file
9
src/Framework/View/templates/components/cms/hero.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<section class="cms-hero" if="{{fullWidth}}" data-full-width="true">
|
||||
<div class="cms-hero-background" if="{{backgroundImage}}" style="background-image: url('{{backgroundImage}}');"></div>
|
||||
<div class="cms-hero-content">
|
||||
<h1 class="cms-hero-title" if="{{title}}">{{title}}</h1>
|
||||
<p class="cms-hero-subtitle" if="{{subtitle}}">{{subtitle}}</p>
|
||||
<a href="{{ctaLink}}" class="cms-hero-cta" if="{{ctaText}}">{{ctaText}}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
8
src/Framework/View/templates/components/cms/image.html
Normal file
8
src/Framework/View/templates/components/cms/image.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<figure class="cms-image">
|
||||
<img
|
||||
src="{{imageUrl}}"
|
||||
alt="{{alt}}"
|
||||
/>
|
||||
<figcaption class="cms-image-caption" if="{{caption}}">{{caption}}</figcaption>
|
||||
</figure>
|
||||
|
||||
4
src/Framework/View/templates/components/cms/text.html
Normal file
4
src/Framework/View/templates/components/cms/text.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="cms-text" style="text-align: {{alignment}};">
|
||||
{{{content}}}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user