fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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>"

View File

@@ -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,

View File

@@ -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
{

View File

@@ -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

View 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');
}
}

View File

@@ -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 = [];

View File

@@ -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 = [];

View File

@@ -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);

View File

@@ -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 = [];

View File

@@ -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 ?? '');

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 = '';
}

View 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');
}
}

View 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);
}
}

View File

@@ -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();
}

View 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}");
}
}

View 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}";
}
}

View File

@@ -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(

View File

@@ -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());

View File

@@ -12,4 +12,5 @@ enum NodeType: string
case COMMENT = 'comment';
case CDATA = 'cdata';
case DOCTYPE = 'doctype';
case RAW_HTML = 'raw-html';
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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 = '';

View File

@@ -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);
}
}
}

View File

@@ -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 (&#039; -> ', &quot; -> ")
$decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Process placeholders with decoded value
$processedValue = $this->placeholderProcessor->processLoopVariable($decodedValue, $varName, $item);
// Set the processed value (will be re-encoded if needed during rendering)
$node->setAttribute($attrName, $processedValue);
}
}
// Process children recursively
foreach ($node->getChildren() as $child) {
$this->replacePlaceholdersInNode($child, $varName, $item);
$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
);
}
}

View 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);
}
}
}

View File

@@ -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;
}
/**

View File

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

View File

@@ -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);
}
}
}

View 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 (&#039; -> ', &quot; -> ")
$decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Check if this is a single placeholder that might return an array/object
// (e.g., pagination="{{$pagination}}")
if (preg_match('/^{{\s*([^}]+?)\s*}}$/', $decodedValue, $matches)) {
$expression = trim($matches[1]);
$allVariables = $context->getAllVariables();
try {
$value = $this->evaluator->evaluate($expression, $allVariables);
// If the value is an array or object, store it directly (not as string)
// XComponentTransformer will handle it correctly
if (is_array($value) || is_object($value)) {
// Store as special marker that XComponentTransformer can recognize
// We'll use a JSON-encoded string that XComponentTransformer can decode
$node->setAttribute($attrName, json_encode($value, JSON_THROW_ON_ERROR));
continue;
}
} catch (\Throwable $e) {
// If evaluation fails, fall through to normal processing
}
}
// For href attributes, use raw processing to avoid escaping URLs
// URLs should not be escaped (they will be escaped by setAttribute if needed)
if ($attrName === 'href' && str_contains($decodedValue, '{{')) {
$processedValue = $this->processPlaceholdersRaw($decodedValue, $context);
} else {
// Process placeholders with escaping
$result = $this->processPlaceholders($decodedValue, $context);
$processedValue = $result['html'];
}
// Set the processed value (will be re-encoded if needed during rendering)
$node->setAttribute($attrName, $processedValue);
}
}
// Process children recursively
foreach ($node->getChildren() as $child) {
$this->processNode($child, $context);
}
}
/**
* Process all placeholders in a string
*
* @return array{html: string, isRawHtml: bool} HTML-String und Flag, ob Raw HTML enthalten ist
*/
private function processPlaceholders(string $html, RenderContext $context): array
{
// Get all variables from scopes and data
$allVariables = $context->getAllVariables();
// Prüfe, ob der Original-Text {{{ }}} Platzhalter enthält
$containsRawPlaceholders = preg_match('/{{{/', $html) === 1;
// Step 1: Process template functions first (e.g., {{ date('Y-m-d') }})
$html = $this->processTemplateFunctions($html, $context, $allVariables);
// Step 2: Process raw HTML placeholders {{{ expression }}
$html = $this->processRawPlaceholders($html, $allVariables);
// Step 3: Process standard placeholders {{ expression }}
$html = $this->processStandardPlaceholders($html, $allVariables);
// Prüfe, ob das Ergebnis HTML-Tags enthält (wenn Raw Platzhalter vorhanden waren)
$isRawHtml = $containsRawPlaceholders && preg_match('/<[a-z][\s\S]*>/i', $html) === 1;
return ['html' => $html, 'isRawHtml' => $isRawHtml];
}
/**
* Process template functions: {{ functionName(params) }}
*/
private function processTemplateFunctions(string $html, RenderContext $context, array $allVariables): string
{
return preg_replace_callback(
'/{{\\s*([a-zA-Z_][a-zA-Z0-9_]*)\\(([^)]*)\\)\\s*}}/',
function ($matches) use ($context, $allVariables) {
$functionName = $matches[1];
$params = trim($matches[2]);
// Check TemplateFunctions registry first
$functions = new TemplateFunctions(
$this->container,
AssetSlotFunction::class,
LazyComponentFunction::class,
UrlFunction::class,
ViteTagsFunction::class
);
if ($functions->has($functionName)) {
$function = $functions->get($functionName);
$args = $this->parseParams($params, $allVariables);
if (is_callable($function)) {
return $function(...$args);
}
}
// Check allowed template functions
if (!in_array($functionName, $this->allowedTemplateFunctions)) {
return $matches[0]; // Return unchanged if not allowed
}
try {
$args = $this->parseParams($params, $allVariables);
// Custom template functions
if (method_exists($this, 'function_' . $functionName)) {
$result = $this->{'function_' . $functionName}(...$args);
return $result;
}
// Standard PHP functions (limited)
if (function_exists($functionName)) {
$result = $functionName(...$args);
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
} catch (\Throwable $e) {
return $matches[0]; // Return unchanged on error
}
return $matches[0];
},
$html
);
}
/**
* Validate expression against dangerous patterns
*/
private function validateExpression(string $expression): void
{
$dangerousPatterns = [
'/eval\s*\(/i',
'/exec\s*\(/i',
'/system\s*\(/i',
'/shell_exec\s*\(/i',
'/passthru\s*\(/i',
'/proc_open\s*\(/i',
'/popen\s*\(/i',
'/`.*`/', // Backticks
'/\$_(GET|POST|REQUEST|COOKIE|SERVER|ENV|FILES)/i', // Superglobals
];
foreach ($dangerousPatterns as $pattern) {
if (preg_match($pattern, $expression)) {
throw PlaceholderException::invalidExpression(
$expression,
'Potentially dangerous expression detected'
);
}
}
}
/**
* Evaluate a literal value (string, number, boolean)
*/
private function evaluateLiteral(string $expression): mixed
{
$expression = trim($expression);
// String literals
if (preg_match('/^["\'](.*)["\']$/', $expression, $matches)) {
return $matches[1];
}
// Numbers
if (is_numeric($expression)) {
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
}
// Boolean literals
if ($expression === 'true') return true;
if ($expression === 'false') return false;
if ($expression === 'null') return null;
// Return as string if nothing else matches
return $expression;
}
/**
* Format value for logging (safe for error_log)
*/
private function formatValueForLog(mixed $value): string
{
if (is_string($value)) {
return strlen($value) > 50 ? substr($value, 0, 50) . '...' : $value;
}
if (is_array($value)) {
return 'array[' . count($value) . ']';
}
if (is_object($value)) {
return get_class($value);
}
return (string) $value;
}
/**
* Process raw HTML placeholders: {{{ expression }}}
* Supports: {{{ $data_table }}}, {{{ data_table }}}, {{{ $pagination['first_url'] }}}
*/
private function processRawPlaceholders(string $html, array $allVariables): string
{
$debugMode = getenv('APP_DEBUG') === 'true';
return preg_replace_callback(
'/{{{\\s*(\\$?[a-zA-Z0-9_]+(?:\\[[^\\]]+\\])*)\\s*}}}/',
function ($matches) use ($allVariables, $debugMode) {
$expression = trim($matches[1]);
try {
$this->validateExpression($expression);
// Handle fallback syntax
if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $fallbackMatches)) {
$primaryExpression = trim($fallbackMatches[1]);
$fallbackExpression = trim($fallbackMatches[2]);
try {
$this->validateExpression($primaryExpression);
$value = $this->evaluator->evaluate($primaryExpression, $allVariables);
if ($value !== null) {
if ($debugMode) {
error_log("[Placeholder] RAW SUCCESS: {$primaryExpression} -> " . $this->formatValueForLog($value));
}
return $this->formatter->formatRaw($value);
}
} catch (\Throwable $e) {
if ($debugMode) {
error_log("[Placeholder] RAW FAILED: {$primaryExpression} - {$e->getMessage()}, using fallback");
}
}
$fallbackValue = $this->evaluateLiteral($fallbackExpression);
if ($debugMode) {
error_log("[Placeholder] RAW FALLBACK: Using '{$fallbackExpression}' -> " . $this->formatValueForLog($fallbackValue));
}
return $this->formatter->formatRaw($fallbackValue);
}
$value = $this->evaluator->evaluate($expression, $allVariables);
if ($debugMode && str_contains($expression, 'table')) {
error_log("[Placeholder] RAW: expression={$expression}, type=" . gettype($value) . ", class=" . (is_object($value) ? get_class($value) : 'N/A') . ", isRawHtml=" . ($value instanceof RawHtml ? 'yes' : 'no'));
}
$formatted = $this->formatter->formatRaw($value);
if ($debugMode && str_contains($expression, 'table')) {
error_log("[Placeholder] RAW: formatted length=" . strlen($formatted) . ", starts with <table=" . (str_starts_with($formatted, '<table') ? 'yes' : 'no'));
}
return $formatted;
} catch (PlaceholderException $e) {
if ($debugMode) {
error_log("[Placeholder] RAW ERROR: {$expression} - {$e->getMessage()}");
}
return '';
} catch (\Throwable $e) {
if ($debugMode) {
error_log("[Placeholder] RAW EXCEPTION: {$expression} - {$e->getMessage()}");
}
return '';
}
},
$html
);
}
/**
* Process placeholders in attributes without escaping (for URLs in href attributes)
*/
private function processPlaceholdersRaw(string $html, RenderContext $context): string
{
$allVariables = $context->getAllVariables();
// Process both {{{ }}} and {{ }} placeholders, but don't escape the result
// First handle {{{ }}} placeholders
$html = preg_replace_callback(
'/{{{\\s*([^}]+?)\\s*}}}/',
function ($matches) use ($allVariables) {
$expression = trim($matches[1]);
$value = $this->evaluator->evaluate($expression, $allVariables);
return $this->formatter->formatRaw($value);
},
$html
);
// Then handle {{ }} placeholders (but don't escape)
// Use the same logic as processStandardPlaceholders but without escaping
return preg_replace_callback(
'/{{\\s*([^}]+?)\\s*}}/',
function ($matches) use ($allVariables) {
$expression = trim($matches[1]);
// Check for method call: expression(params)
if (preg_match('/^(.+?)\\(\\s*([^)]*)\\s*\\)$/', $expression, $methodMatches)) {
$methodExpr = trim($methodMatches[1]);
$params = trim($methodMatches[2]);
$value = $this->processMethodCall($methodExpr, $params, $allVariables);
return (string) $value;
}
// Try to resolve array bracket notation first (e.g., $pagination['first_url'] or pagination['first_url'])
// Pattern: $var['key'] or var['key'] or $var["key"] or var["key"]
if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[(["\']?)([^"\'\]]+)\\2\\]$/', $expression, $arrayMatches)) {
$varName = $arrayMatches[1];
$key = $arrayMatches[3];
// Check if variable exists in context
if (array_key_exists($varName, $allVariables)) {
$value = $allVariables[$varName];
// Handle numeric key
if (is_numeric($key)) {
$key = (int) $key;
}
// Access array or object property
if (is_array($value) && array_key_exists($key, $value)) {
$result = $value[$key];
// Convert to string without escaping
if (is_bool($result)) {
return $result ? 'true' : 'false';
}
if ($result === null) {
return '';
}
return (string) $result;
}
if (is_object($value)) {
if (isset($value->$key)) {
$result = $value->$key;
if (is_bool($result)) {
return $result ? 'true' : 'false';
}
if ($result === null) {
return '';
}
return (string) $result;
}
}
}
return '';
}
// Try ExpressionEvaluator as fallback
try {
$value = $this->evaluator->evaluate($expression, $allVariables);
// Convert to string without escaping
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value === null) {
return '';
}
if ($value instanceof RawHtml) {
return $value->content;
}
return (string) $value;
} catch (\Throwable $e) {
// If evaluation fails, return original placeholder
if (getenv('APP_DEBUG') === 'true') {
error_log("PlaceholderTransformer::processPlaceholdersRaw: Failed to evaluate '{$expression}': " . $e->getMessage());
}
return $matches[0];
}
},
$html
);
}
/**
* Process standard placeholders: {{ expression }}
*/
private function processStandardPlaceholders(string $html, array $allVariables): string
{
// Pattern matches: {{ expression }} where expression can contain:
// - Variables: $var, var
// - Array access: $var['key'], $var["key"], $var[0]
// - Dot notation: var.property
// - Method calls: object.method()
// Note: We need to handle both $var['key'] and var['key'] syntax
// Debug: Log available variables (only first time)
static $logged = false;
if (!$logged && !empty($allVariables)) {
error_log("PlaceholderTransformer: Available variables: " . implode(', ', array_keys($allVariables)));
$logged = true;
}
return preg_replace_callback(
'/{{\\s*([^}]+?)\\s*}}/',
function ($matches) use ($allVariables) {
$expression = trim($matches[1]);
// Debug: Log expression being processed
if (str_contains($expression, 'stack') || str_contains($expression, 'container')) {
error_log("PlaceholderTransformer: Processing expression: {$expression}");
error_log("PlaceholderTransformer: Available vars: " . implode(', ', array_keys($allVariables)));
}
// Check for method call: expression(params)
if (preg_match('/^(.+?)\\(\\s*([^)]*)\\s*\\)$/', $expression, $methodMatches)) {
$methodExpr = trim($methodMatches[1]);
$params = trim($methodMatches[2]);
return $this->processMethodCall($methodExpr, $params, $allVariables);
}
// Try to resolve array bracket notation first (e.g., $container['id'] or container['id'])
// This handles cases where ExpressionEvaluator might not support nested array access
// Pattern: $var['key'] or var['key'] or $var["key"] or var["key"]
if (preg_match('/^\\$?([a-zA-Z_][a-zA-Z0-9_]*)\\[(["\']?)([^"\'\]]+)\\2\\]$/', $expression, $arrayMatches)) {
$varName = $arrayMatches[1];
$key = $arrayMatches[3];
// Check if variable exists in context
if (array_key_exists($varName, $allVariables)) {
$value = $allVariables[$varName];
// Handle numeric key
if (is_numeric($key)) {
$key = (int) $key;
}
// Access array or object property
if (is_array($value)) {
if (array_key_exists($key, $value)) {
return $this->formatEscapedValue($value[$key]);
}
} elseif (is_object($value)) {
// Try direct property access
if (isset($value->$key)) {
return $this->formatEscapedValue($value->$key);
}
// Try toArray() for Value Objects
if (method_exists($value, 'toArray')) {
$array = $value->toArray();
if (is_array($array) && array_key_exists($key, $array)) {
return $this->formatEscapedValue($array[$key]);
}
}
}
}
// If variable not found or key doesn't exist, fall through to ExpressionEvaluator
}
// Try dot notation: $stack.display_name or stack.display_name
// Remove leading $ for variable lookup
$varNameForLookup = ltrim($expression, '$');
if (str_contains($varNameForLookup, '.') && !str_contains($varNameForLookup, '[')) {
$parts = explode('.', $varNameForLookup, 2);
$varName = trim($parts[0]);
$property = trim($parts[1]);
if (array_key_exists($varName, $allVariables)) {
$obj = $allVariables[$varName];
if (is_array($obj) && array_key_exists($property, $obj)) {
return $this->formatEscapedValue($obj[$property]);
}
if (is_object($obj)) {
if (isset($obj->$property)) {
return $this->formatEscapedValue($obj->$property);
}
if (method_exists($obj, 'toArray')) {
$array = $obj->toArray();
if (is_array($array) && array_key_exists($property, $array)) {
return $this->formatEscapedValue($array[$property]);
}
}
}
}
}
// Handle fallback syntax: {{ $var ?? 'default' }}
$debugMode = getenv('APP_DEBUG') === 'true';
if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $fallbackMatches)) {
$primaryExpression = trim($fallbackMatches[1]);
$fallbackExpression = trim($fallbackMatches[2]);
try {
$this->validateExpression($primaryExpression);
$value = $this->evaluator->evaluate($primaryExpression, $allVariables);
if ($value !== null) {
if ($debugMode) {
error_log("[Placeholder] SUCCESS: {$primaryExpression} -> " . $this->formatValueForLog($value));
}
return $this->formatEscapedValue($value);
}
} catch (\Throwable $e) {
if ($debugMode) {
error_log("[Placeholder] FAILED: {$primaryExpression} - {$e->getMessage()}, using fallback");
}
}
// Use fallback
$fallbackValue = $this->evaluateLiteral($fallbackExpression);
if ($debugMode) {
error_log("[Placeholder] FALLBACK: Using '{$fallbackExpression}' -> " . $this->formatValueForLog($fallbackValue));
}
return $this->formatEscapedValue($fallbackValue);
}
// Fallback to ExpressionEvaluator for other expressions
// ExpressionEvaluator supports: $var['key'], $var->prop, var.property
// For array access with $, keep the $; for simple variables, ExpressionEvaluator handles both
try {
$this->validateExpression($expression);
$value = $this->evaluator->evaluate($expression, $allVariables);
if ($value === null) {
if ($debugMode) {
error_log("[Placeholder] NULL: {$expression} - Variable not found or returned null");
}
return '';
}
return $this->formatEscapedValue($value);
} catch (PlaceholderException $e) {
if ($debugMode) {
error_log("[Placeholder] ERROR: {$expression} - {$e->getMessage()}");
}
return '';
} catch (\Throwable $e) {
if ($debugMode) {
error_log("[Placeholder] EXCEPTION: {$expression} - {$e->getMessage()}");
}
return '';
}
},
$html
);
}
/**
* Process method calls: {{ object.method(params) }}
*/
private function processMethodCall(string $expression, string $params, array $allVariables): string
{
$parts = explode('.', $expression);
$methodName = array_pop($parts);
$objectPath = implode('.', $parts);
// Resolve object
$object = $this->evaluator->evaluate($objectPath, $allVariables);
if (!is_object($object)) {
return '{{ ' . $expression . '() }}'; // Keep placeholder if object not found
}
if (!method_exists($object, $methodName)) {
return '{{ ' . $expression . '() }}'; // Keep placeholder if method not found
}
try {
$parsedParams = empty($params) ? [] : $this->parseParams($params, $allVariables);
$result = $object->$methodName(...$parsedParams);
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} catch (\Throwable $e) {
return '{{ ' . $expression . '() }}'; // Keep placeholder on error
}
}
/**
* Format value for escaped output
*/
private function formatEscapedValue(mixed $value): string
{
return $this->formatter->formatEscaped($value);
}
/**
* Format value for raw output
*/
private function formatRawValue(mixed $value): string
{
return $this->formatter->formatRaw($value);
}
/**
* Parse parameters from a parameter string
*/
private function parseParams(string $paramsString, array $data): array
{
if (empty(trim($paramsString))) {
return [];
}
$params = [];
$parts = $this->splitParams($paramsString);
foreach ($parts as $part) {
$part = trim($part);
// String literals: 'text' or "text"
if (preg_match('/^[\'\"](.*)[\'\"]$/s', $part, $matches)) {
$params[] = $matches[1];
continue;
}
// Numbers
if (is_numeric($part)) {
$params[] = str_contains($part, '.') ? (float)$part : (int)$part;
continue;
}
// Boolean
if ($part === 'true') {
$params[] = true;
continue;
}
if ($part === 'false') {
$params[] = false;
continue;
}
// Null
if ($part === 'null') {
$params[] = null;
continue;
}
// Variable references: $variable or paths: object.property
if (str_starts_with($part, '$')) {
$varName = substr($part, 1);
if (array_key_exists($varName, $data)) {
$params[] = $data[$varName];
continue;
}
} elseif (str_contains($part, '.')) {
$value = $this->evaluator->evaluate($part, $data);
if ($value !== null) {
$params[] = $value;
continue;
}
} else {
// Simple variable names (without $ or .) resolve from template data
$value = $this->evaluator->evaluate($part, $data);
if ($value !== null) {
$params[] = $value;
continue;
}
}
// Fallback: treat as string
$params[] = $part;
}
return $params;
}
/**
* Split parameter string into individual parameters
*/
private function splitParams(string $paramsString): array
{
$params = [];
$current = '';
$inQuotes = false;
$quoteChar = null;
$length = strlen($paramsString);
for ($i = 0; $i < $length; $i++) {
$char = $paramsString[$i];
if (!$inQuotes && ($char === '"' || $char === "'")) {
$inQuotes = true;
$quoteChar = $char;
$current .= $char;
} elseif ($inQuotes && $char === $quoteChar) {
$inQuotes = false;
$quoteChar = null;
$current .= $char;
} elseif (!$inQuotes && $char === ',') {
$params[] = trim($current);
$current = '';
} else {
$current .= $char;
}
}
if ($current !== '') {
$params[] = trim($current);
}
return $params;
}
/**
* Custom template functions
*/
private function function_format_date(string|DateTime $date, string $format = 'Y-m-d H:i:s'): string
{
if (is_string($date)) {
$date = new \DateTimeImmutable($date, new DateTimeZone('Europe/Berlin'));
}
return $date->format($format);
}
private function function_format_currency(float $amount, string $currency = 'EUR'): string
{
return number_format($amount, 2, ',', '.') . ' ' . $currency;
}
private function function_format_filesize(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$factor = floor((strlen((string)$bytes) - 1) / 3);
return sprintf("%.1f %s", $bytes / pow(1024, $factor), $units[$factor]);
}
}

View File

@@ -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)
*/

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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 = [];

View 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 . ']';
}
}

View 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');
}
}

View File

@@ -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);
}
}

View 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;
}

View File

@@ -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

View 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;
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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}>";

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)
*

View File

@@ -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;
}
}

View 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;
}
}

View 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 => '📎',
};
}
}

View File

@@ -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;
}
};
}
}

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View 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, '-'));
}
}

View 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, '-'));
}
}

View 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, '-'));
}
}

View 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;
}
}

View 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;
}

View 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, '-'));
}
}

View File

@@ -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);

View 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
)
);
}
}
}

View File

@@ -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);

View File

@@ -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, '-'));
}
}

View File

@@ -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, '-'));
}
}

View File

@@ -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, '-'));
}
}

View 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, '-'));
}
}

View 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;
}
}

View 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, '-'));
}
}

View File

@@ -37,6 +37,7 @@ enum TagName: string
case THEAD = 'thead';
case TBODY = 'tbody';
case TFOOT = 'tfoot';
case SMALL = 'small';
public function isSelfClosing(): bool
{

View 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);
}
}

View 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, '-'));
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
<layout src="main">
{{{bodyContent}}}
</layout>

View 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>

View File

@@ -0,0 +1,8 @@
<figure class="cms-image">
<img
src="{{imageUrl}}"
alt="{{alt}}"
/>
<figcaption class="cms-image-caption" if="{{caption}}">{{caption}}</figcaption>
</figure>

View 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