feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Framework\View;
use App\Framework\Core\ValueObjects\Hash;
/**
* Compiled Template Value Object
*
* Repräsentiert ein vorverarbeitetes Template mit strukturierten Daten
* für schnelleres Re-Rendering.
*/
final readonly class CompiledTemplate
{
/**
* @param string $templatePath Original template path
* @param array<string> $placeholders Extracted placeholders
* @param array<string> $components Referenced components
* @param array<ProcessorInstruction> $instructions Processor instructions
* @param int $compiledAt Unix timestamp when compiled
* @param Hash $contentHash Hash of original content
*/
public function __construct(
public string $templatePath,
public array $placeholders,
public array $components,
public array $instructions,
public int $compiledAt,
public Hash $contentHash
) {}
/**
* Konvertiert zu Array für Caching
*/
public function toArray(): array
{
return [
'template_path' => $this->templatePath,
'placeholders' => $this->placeholders,
'components' => $this->components,
'instructions' => array_map(
fn(ProcessorInstruction $i) => $i->toArray(),
$this->instructions
),
'compiled_at' => $this->compiledAt,
'content_hash' => $this->contentHash->toString(),
];
}
/**
* Rekonstruiert aus Array
*/
public static function fromArray(array $data): self
{
return new self(
templatePath: $data['template_path'],
placeholders: $data['placeholders'],
components : $data['components'],
instructions: array_map(
fn(array $i) => ProcessorInstruction::fromArray($i),
$data['instructions']
),
compiledAt : $data['compiled_at'],
contentHash : Hash::fromString($data['content_hash'])
);
}
/**
* Prüft ob Template bestimmte Features verwendet
*/
public function hasPlaceholders(): bool
{
return !empty($this->placeholders);
}
public function hasComponents(): bool
{
return !empty($this->components);
}
public function getInstructionCount(): int
{
return count($this->instructions);
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Hash;
/**
* Compiled Template Cache
*
* Cached vorverarbeitete Templates (AST) statt nur finales HTML.
* Ermöglicht schnellere Re-Rendering mit unterschiedlichen Daten.
*
* Performance Impact:
* - ~50-60% schnelleres Template-Rendering bei Cache-Hit
* - Reduzierter Parsing-Overhead
* - Bessere Memory-Efficiency durch strukturiertes Caching
*
* Cache Strategy:
* - Template AST wird nach erstem Parsing gecached
* - Staleness-Detection via File-Modification-Time
* - Separate Caching für verschiedene Template-Varianten
*/
final readonly class CompiledTemplateCache
{
private const string CACHE_KEY_PREFIX = 'template:compiled:';
private const int CACHE_TTL_HOURS = 24;
public function __construct(
private Cache $cache
) {
}
/**
* Ruft kompiliertes Template aus Cache ab oder kompiliert neu
*
* @param string $templatePath
* @param callable $compiler Function to compile template: fn(string $content): CompiledTemplate
* @return CompiledTemplate
*/
public function remember(string $templatePath, callable $compiler): CompiledTemplate
{
$cacheKey = $this->getCacheKey($templatePath);
// Try cache first
$cacheItem = $this->cache->get($cacheKey);
if ($cacheItem->isHit) {
$compiled = CompiledTemplate::fromArray($cacheItem->value);
// Check staleness
if (! $this->isStale($compiled, $templatePath)) {
return $compiled;
}
}
// Cache miss or stale - compile and cache
$templateContent = file_get_contents($templatePath);
if ($templateContent === false) {
throw new \RuntimeException("Failed to read template: {$templatePath}");
}
$compiled = $compiler($templateContent);
// Cache with template file modification time
$this->cache->set(
CacheItem::forSet(
key: $cacheKey,
value: $compiled->toArray(),
ttl: Duration::fromHours(self::CACHE_TTL_HOURS)
)
);
return $compiled;
}
/**
* Prüft ob kompiliertes Template veraltet ist
*/
private function isStale(CompiledTemplate $compiled, string $templatePath): bool
{
if (! file_exists($templatePath)) {
return true;
}
$fileModifiedTime = filemtime($templatePath);
if ($fileModifiedTime === false) {
return true;
}
return $fileModifiedTime > $compiled->compiledAt;
}
/**
* Invalidiert Cache für spezifisches Template
*/
public function invalidate(string $templatePath): bool
{
$cacheKey = $this->getCacheKey($templatePath);
return $this->cache->forget($cacheKey);
}
/**
* Generiert Cache-Key für Template
*/
private function getCacheKey(string $templatePath): CacheKey
{
$hash = Hash::sha256($templatePath);
return CacheKey::fromString(self::CACHE_KEY_PREFIX . $hash->toShort(16));
}
}

View File

@@ -0,0 +1,201 @@
<?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\Core\ValueObjects\LinkRel;
use App\Framework\Core\ValueObjects\LinkTarget;
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;
/**
* <x-a> Component
*
* Wrapper around HtmlLink and AccessibleLink Value Objects for template usage.
* Provides consistent, accessible link rendering throughout the application.
*
* Usage in templates:
* <x-a href="/dashboard">Dashboard</x-a>
* <x-a href="https://example.com" external="true">External Link</x-a>
* <x-a href="/current" aria-current="true">Current Page</x-a>
* <x-a href="/downloads/file.pdf" download="true">Download PDF</x-a>
* <x-a href="/page" class="custom-link" title="Tooltip">Link with custom class</x-a>
*/
#[ComponentName('a')]
final readonly class A implements StaticComponent
{
private AccessibleLink $link;
private string $text;
public function __construct(
string $content = '',
array $attributes = []
) {
// Content is the link text
$this->text = $content;
// Extract href from attributes (default to '#' if not provided)
$href = $attributes['href'] ?? '#';
// Extract other link attributes
$title = $attributes['title'] ?? null;
$class = $attributes['class'] ?? null;
$target = $attributes['target'] ?? null;
$rel = $attributes['rel'] ?? null;
$external = isset($attributes['external']);
$download = $attributes['download'] ?? null;
// Extract ARIA attributes
$ariaLabel = $attributes['aria-label'] ?? null;
$ariaCurrent = $attributes['aria-current'] ?? null;
$ariaDescribedBy = $attributes['aria-described-by'] ?? null;
// Build HtmlLink
$htmlLink = HtmlLink::create($href, $this->text);
if ($title !== null) {
$htmlLink = $htmlLink->withTitle($title);
}
if ($class !== null) {
$htmlLink = $htmlLink->withClass($class);
}
// Handle target attribute
if ($target !== null) {
$linkTarget = match ($target) {
'_blank' => LinkTarget::BLANK,
'_self' => LinkTarget::SELF,
'_parent' => LinkTarget::PARENT,
'_top' => LinkTarget::TOP,
default => null
};
if ($linkTarget !== null) {
$htmlLink = $htmlLink->withTarget($linkTarget);
}
}
// Handle rel attribute
if ($rel !== null) {
$relValues = array_map(
fn(string $r) => match (trim($r)) {
'noopener' => LinkRel::NOOPENER,
'noreferrer' => LinkRel::NOREFERRER,
'nofollow' => LinkRel::NOFOLLOW,
'external' => LinkRel::EXTERNAL,
default => null
},
explode(' ', $rel)
);
$relValues = array_filter($relValues);
if (!empty($relValues)) {
$htmlLink = $htmlLink->withRel(...$relValues);
}
}
// Handle external links (auto-adds security attributes)
if ($external) {
$htmlLink = HtmlLink::external($href, $this->text);
if ($class !== null) {
$htmlLink = $htmlLink->withClass($class);
}
if ($title !== null) {
$htmlLink = $htmlLink->withTitle($title);
}
}
// Handle downloads
if ($download !== null) {
$htmlLink = HtmlLink::download($href, $this->text, $download !== '' ? $download : null);
if ($class !== null) {
$htmlLink = $htmlLink->withClass($class);
}
if ($title !== null) {
$htmlLink = $htmlLink->withTitle($title);
}
}
// Wrap in AccessibleLink for ARIA support
$link = AccessibleLink::fromHtmlLink($htmlLink);
if ($ariaLabel !== null) {
$link = $link->withAriaLabel($ariaLabel);
}
if ($ariaCurrent !== null) {
$link = $link->withAriaCurrent(true, $ariaCurrent);
}
if ($ariaDescribedBy !== null) {
$link = new AccessibleLink(
baseLink: $link->baseLink,
ariaLabel: $link->ariaLabel,
ariaDescribedBy: $ariaDescribedBy,
ariaCurrent: $link->ariaCurrent,
ariaCurrentValue: $link->ariaCurrentValue,
);
}
// Store the AccessibleLink
$this->link = $link;
}
public function getRootNode(): Node
{
$a = new ElementNode('a');
// Add href
$a->setAttribute('href', (string) $this->link->baseLink->href);
// Add class if present
if ($this->link->baseLink->cssClass !== null) {
$a->setAttribute('class', $this->link->baseLink->cssClass);
}
// Add title if present
if ($this->link->baseLink->title !== null) {
$a->setAttribute('title', $this->link->baseLink->title);
}
// Add target if present
if ($this->link->baseLink->target !== null) {
$a->setAttribute('target', $this->link->baseLink->target->value);
}
// Add rel if present
if (!empty($this->link->baseLink->rel)) {
$relValues = array_map(fn($r) => $r->value, $this->link->baseLink->rel);
$a->setAttribute('rel', implode(' ', $relValues));
}
// Add download if present
if ($this->link->baseLink->download !== null) {
$a->setAttribute('download', $this->link->baseLink->download);
}
// Add ARIA attributes
if ($this->link->ariaLabel !== null) {
$a->setAttribute('aria-label', $this->link->ariaLabel);
}
if ($this->link->ariaCurrent) {
$a->setAttribute('aria-current', $this->link->ariaCurrentValue ?? 'true');
}
if ($this->link->ariaDescribedBy !== null) {
$a->setAttribute('aria-describedby', $this->link->ariaDescribedBy);
}
// Add text content
$textNode = new TextNode($this->text);
$a->appendChild($textNode);
return $a;
}
}

View File

@@ -0,0 +1,123 @@
<?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;
#[ComponentName('admin-header')]
final readonly class AdminHeader implements StaticComponent
{
private string $pageTitle;
private bool $showSearch;
public function __construct(
string $content = '',
array $attributes = []
) {
// Extract attributes with defaults
$this->pageTitle = $attributes['page-title'] ?? 'Admin';
$this->showSearch = isset($attributes['show-search'])
? filter_var($attributes['show-search'], FILTER_VALIDATE_BOOLEAN)
: true;
}
public function getRootNode(): Node
{
$header = new ElementNode('header');
$header->setAttribute('class', 'admin-header');
$header->setAttribute('role', 'banner');
// Use TextNode for HTML content
$content = new TextNode($this->buildHeaderContent());
$header->appendChild($content);
return $header;
}
private function buildHeaderContent(): string
{
$searchHtml = $this->showSearch ? $this->buildSearchBar() : '';
return <<<HTML
<h1 class="admin-header__title">{$this->pageTitle}</h1>
{$searchHtml}
<div class="admin-header__actions">
<button
class="admin-action-btn"
aria-label="Notifications"
data-dropdown-trigger="notifications"
>
<span class="admin-action-btn__icon">🔔</span>
<span class="admin-action-btn__badge admin-action-btn__badge--count">3</span>
</button>
<button
class="admin-theme-toggle"
data-theme-toggle
aria-label="Toggle theme"
title="Toggle dark/light mode"
>
<span class="admin-theme-toggle__icon" data-theme-icon="light" style="display: none;">☀️</span>
<span class="admin-theme-toggle__icon" data-theme-icon="dark" style="display: none;">🌙</span>
<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__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>
</div>
</div>
HTML;
}
private function buildSearchBar(): string
{
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>
</div>
HTML;
}
}

View File

@@ -0,0 +1,125 @@
<?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;
#[ComponentName('admin-sidebar')]
final readonly class AdminSidebar implements StaticComponent
{
private string $title;
private string $logoUrl;
private string $currentPath;
public function __construct(
string $content = '',
array $attributes = []
) {
// Extract attributes with defaults
$this->title = $attributes['title'] ?? 'Admin Panel';
$this->logoUrl = $attributes['logo-url'] ?? '/admin';
$this->currentPath = $attributes['current-path'] ?? '/admin';
}
public function getRootNode(): Node
{
$nav = new ElementNode('nav');
$nav->setAttribute('class', 'admin-sidebar');
$nav->setAttribute('role', 'navigation');
$nav->setAttribute('aria-label', 'Admin navigation');
// Use TextNode for HTML content (will be parsed by HtmlRenderer)
$content = new TextNode($this->buildSidebarContent());
$nav->appendChild($content);
return $nav;
}
private function buildSidebarContent(): string
{
return <<<HTML
<div class="admin-sidebar__header">
<a href="{$this->logoUrl}" class="admin-sidebar__logo-link">
<span class="admin-sidebar__title">{$this->title}</span>
</a>
</div>
<nav class="admin-nav">
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">Dashboard</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin" class="admin-nav__link" {$this->getActiveState('/admin')}>
<span class="admin-nav__icon">📊</span>
<span>Overview</span>
</a>
</li>
</ul>
</div>
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">System</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/infrastructure/cache" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/cache')}>
<span class="admin-nav__icon">💾</span>
<span>Cache</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/infrastructure/logs" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/logs')}>
<span class="admin-nav__icon">📝</span>
<span>Logs</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/infrastructure/migrations" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/migrations')}>
<span class="admin-nav__icon">🔄</span>
<span>Migrations</span>
</a>
</li>
</ul>
</div>
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">Content</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/images" class="admin-nav__link" {$this->getActiveState('/admin/images')}>
<span class="admin-nav__icon">🖼️</span>
<span>Images</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/users" class="admin-nav__link" {$this->getActiveState('/admin/users')}>
<span class="admin-nav__icon">👥</span>
<span>Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="admin-sidebar__footer">
<div class="admin-sidebar__user">
<div class="admin-sidebar__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>
<div class="admin-sidebar__user-info">
<span class="admin-sidebar__user-name">Admin User</span>
<span class="admin-sidebar__user-role">Administrator</span>
</div>
</div>
</div>
HTML;
}
private function getActiveState(string $path): string
{
return $path === $this->currentPath ? 'aria-current="page"' : '';
}
}

View File

@@ -5,87 +5,54 @@ declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\HtmlElement;
use App\Framework\View\ValueObjects\HtmlTag;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\TagName;
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('alert')]
final readonly class Alert implements HtmlElement
final readonly class Alert implements StaticComponent
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
private string $message;
private string $variant;
private ?string $title;
private bool $dismissible;
private ?string $icon;
public function __construct(
public string $message,
public string $variant = 'info',
public ?string $title = null,
public bool $dismissible = false,
public ?string $icon = null,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
string $content = '',
array $attributes = []
) {
$this->tag = new HtmlTag(TagName::DIV);
// Content is the alert message
$this->message = $content;
// Extract attributes with defaults
$this->variant = $attributes['variant'] ?? 'info';
$this->title = $attributes['title'] ?? null;
$this->dismissible = isset($attributes['dismissible'])
? filter_var($attributes['dismissible'], FILTER_VALIDATE_BOOLEAN)
: false;
$this->icon = $attributes['icon'] ?? null;
}
public function getRootNode(): Node
{
$div = new ElementNode('div');
// Build CSS classes
$classes = ['alert', "alert--{$this->variant}"];
if ($this->dismissible) {
$classes[] = 'alert--dismissible';
}
$this->attributes = HtmlAttributes::empty()
->withClass(implode(' ', $classes))
->with('role', 'alert');
$div->setAttribute('class', implode(' ', $classes));
$div->setAttribute('role', 'alert');
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
// Build content structure
$contentHtml = $this->buildContent();
$div->appendChild(new TextNode($contentHtml));
$this->content = $this->buildContent();
}
public static function info(string $message, ?string $title = null): self
{
return new self(message: $message, variant: 'info', title: $title);
}
public static function success(string $message, ?string $title = null): self
{
return new self(message: $message, variant: 'success', title: $title);
}
public static function warning(string $message, ?string $title = null): self
{
return new self(message: $message, variant: 'warning', title: $title);
}
public static function danger(string $message, ?string $title = null): self
{
return new self(message: $message, variant: 'danger', title: $title);
}
public function withDismissible(bool $dismissible = true): self
{
return new self(
message: $this->message,
variant: $this->variant,
title: $this->title,
dismissible: $dismissible,
icon: $this->icon,
additionalAttributes: $this->additionalAttributes
);
}
public function withIcon(string $icon): self
{
return new self(
message: $this->message,
variant: $this->variant,
title: $this->title,
dismissible: $this->dismissible,
icon: $icon,
additionalAttributes: $this->additionalAttributes
);
return $div;
}
private function buildContent(): string
@@ -94,11 +61,7 @@ final readonly class Alert implements HtmlElement
// Icon
if ($this->icon !== null) {
$elements[] = StandardHtmlElement::create(
TagName::SPAN,
HtmlAttributes::empty()->withClass('alert__icon'),
$this->icon
);
$elements[] = '<span class="alert__icon">' . htmlspecialchars($this->icon) . '</span>';
}
// Content wrapper
@@ -106,48 +69,40 @@ final readonly class Alert implements HtmlElement
// Title
if ($this->title !== null) {
$contentElements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('alert__title'),
$this->title
);
$contentElements[] = '<div class="alert__title">' . htmlspecialchars($this->title) . '</div>';
}
// Message
$contentElements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('alert__message'),
$this->message
);
$contentElements[] = '<div class="alert__message">' . htmlspecialchars($this->message) . '</div>';
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('alert__content'),
implode('', array_map('strval', $contentElements))
);
$elements[] = '<div class="alert__content">' . implode('', $contentElements) . '</div>';
// Dismiss button
if ($this->dismissible) {
$dismissButton = StandardHtmlElement::create(
TagName::BUTTON,
HtmlAttributes::empty()
->withType('button')
->withClass('alert__close')
->with('aria-label', 'Schließen'),
'×'
);
$elements[] = $dismissButton;
$elements[] = '<button type="button" class="alert__close" aria-label="Schließen">×</button>';
}
return implode('', array_map('strval', $elements));
return implode('', $elements);
}
public function __toString(): string
// Factory methods for programmatic usage
public static function info(string $message, ?string $title = null): self
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
return new self($message, ['variant' => 'info', 'title' => $title]);
}
public static function success(string $message, ?string $title = null): self
{
return new self($message, ['variant' => 'success', 'title' => $title]);
}
public static function warning(string $message, ?string $title = null): self
{
return new self($message, ['variant' => 'warning', 'title' => $title]);
}
public static function danger(string $message, ?string $title = null): self
{
return new self($message, ['variant' => 'danger', 'title' => $title]);
}
}

View File

@@ -5,98 +5,81 @@ declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\HtmlElement;
use App\Framework\View\ValueObjects\HtmlTag;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\TagName;
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('badge')]
final readonly class Badge implements HtmlElement
final readonly class Badge implements StaticComponent
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
private string $text;
private string $variant;
private string $size;
private bool $pill;
public function __construct(
public string $text,
public string $variant = 'default',
public string $size = 'md',
public bool $pill = false,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
string $content = '',
array $attributes = []
) {
$this->tag = new HtmlTag(TagName::SPAN);
$this->content = $text;
// Content is the badge text
$this->text = $content;
// 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;
}
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';
}
$this->attributes = HtmlAttributes::empty()->withClass(implode(' ', $classes));
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
$span->setAttribute('class', implode(' ', $classes));
// Add text content
$textNode = new TextNode($this->text);
$span->appendChild($textNode);
return $span;
}
// Factory methods for programmatic usage (not needed for template usage)
public static function create(string $text): self
{
return new self(text: $text);
return new self($text);
}
public static function primary(string $text): self
{
return new self(text: $text, variant: 'primary');
return new self($text, ['variant' => 'primary']);
}
public static function success(string $text): self
{
return new self(text: $text, variant: 'success');
return new self($text, ['variant' => 'success']);
}
public static function warning(string $text): self
{
return new self(text: $text, variant: 'warning');
return new self($text, ['variant' => 'warning']);
}
public static function danger(string $text): self
{
return new self(text: $text, variant: 'danger');
return new self($text, ['variant' => 'danger']);
}
public static function info(string $text): self
{
return new self(text: $text, variant: 'info');
}
public function asPill(): self
{
return new self(
text: $this->text,
variant: $this->variant,
size: $this->size,
pill: true,
additionalAttributes: $this->additionalAttributes
);
}
public function withSize(string $size): self
{
return new self(
text: $this->text,
variant: $this->variant,
size: $size,
pill: $this->pill,
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
TagName::SPAN,
$this->attributes,
$this->content
);
return new self($text, ['variant' => 'info']);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\Core\ValueObjects\HtmlLink;
use App\Framework\Core\ValueObjects\LinkCollection;
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('breadcrumbs')]
final readonly class Breadcrumbs implements StaticComponent
{
private LinkCollection $links;
/**
* @param string $content Inner content (unused for this component)
* @param array<string, string> $attributes Component attributes
* - items: JSON array [{"url": "/", "text": "Home"}]
*/
public function __construct(
string $content = '',
array $attributes = []
) {
// Parse items from JSON attribute
$items = $attributes['items'] ?? '[]';
$this->links = $this->parseLinksFromJson($items);
}
public function getRootNode(): Node
{
$nav = new ElementNode('nav');
$nav->setAttribute('class', 'admin-breadcrumbs');
$nav->setAttribute('aria-label', 'Breadcrumb');
// Use TextNode for HTML content
$content = new TextNode($this->buildBreadcrumbsContent());
$nav->appendChild($content);
return $nav;
}
private function parseLinksFromJson(string $items): LinkCollection
{
try {
$decoded = json_decode($items, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
return $this->getDefaultLinks();
}
// Use LinkCollection::fromUrlsAndTexts() for clean creation
return LinkCollection::fromUrlsAndTexts($decoded);
} catch (\JsonException) {
return $this->getDefaultLinks();
}
}
private function getDefaultLinks(): LinkCollection
{
return new LinkCollection(
HtmlLink::create(href: '/admin', text: 'Admin')
->withClass('admin-breadcrumbs__link')
);
}
private function buildBreadcrumbsContent(): string
{
if ($this->links->isEmpty()) {
return '';
}
$listItems = [];
$totalLinks = $this->links->count();
foreach ($this->links as $index => $link) {
$isLast = ($index === $totalLinks - 1);
$listItems[] = $this->renderBreadcrumbItem($link, $isLast);
}
$listItemsHtml = implode("\n", $listItems);
return <<<HTML
<ol class="admin-breadcrumbs__list" role="list">
{$listItemsHtml}
</ol>
HTML;
}
private function renderBreadcrumbItem(HtmlLink $link, bool $isLast): string
{
$text = htmlspecialchars($link->text ?? 'Link', ENT_QUOTES, 'UTF-8');
if ($isLast) {
// Current page - no link
return <<<HTML
<li class="admin-breadcrumbs__item">
<span class="admin-breadcrumbs__current" aria-current="page">{$text}</span>
</li>
HTML;
}
// Render link with admin breadcrumb class
$linkWithClass = $link->withClass('admin-breadcrumbs__link');
$linkHtml = $linkWithClass->toHtml();
return <<<HTML
<li class="admin-breadcrumbs__item">
{$linkHtml}
<span class="admin-breadcrumbs__separator" aria-hidden="true"></span>
</li>
HTML;
}
}

View File

@@ -5,183 +5,113 @@ declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\HtmlElement;
use App\Framework\View\ValueObjects\HtmlTag;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\TagName;
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('button')]
final readonly class Button implements HtmlElement
final readonly class Button implements StaticComponent
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
private string $text;
private string $type;
private string $variant;
private string $size;
private ?string $href;
private bool $disabled;
private bool $fullWidth;
private ?string $icon;
public function __construct(
public string $text,
public string $type = 'button',
public string $variant = 'primary',
public string $size = 'md',
public ?string $href = null,
public bool $disabled = false,
public bool $fullWidth = false,
public ?string $icon = null,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
string $content = '',
array $attributes = []
) {
// Determine tag based on href
$this->tag = $this->href !== null
? new HtmlTag(TagName::A)
: new HtmlTag(TagName::BUTTON);
// Content is the button text
$this->text = $content;
// Build classes
// 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;
}
public function getRootNode(): Node
{
// Determine element based on href
$element = $this->href !== null
? new ElementNode('a')
: new ElementNode('button');
// Build CSS classes
$classes = ['btn', "btn--{$this->variant}", "btn--{$this->size}"];
if ($this->fullWidth) {
$classes[] = 'btn--full-width';
}
if ($this->disabled && $this->href !== null) {
$classes[] = 'btn--disabled';
}
// Build attributes
$attributes = HtmlAttributes::empty()
->withClass(implode(' ', $classes));
$element->setAttribute('class', implode(' ', $classes));
// Set element-specific attributes
if ($this->href !== null) {
$attributes = $attributes->with('href', $this->href);
$element->setAttribute('href', $this->href);
if ($this->disabled) {
$attributes = $attributes
->withClass('btn--disabled')
->with('aria-disabled', 'true');
$element->setAttribute('aria-disabled', 'true');
}
} else {
$attributes = $attributes->withType($this->type);
$element->setAttribute('type', $this->type);
if ($this->disabled) {
$attributes = $attributes->withDisabled();
$element->setAttribute('disabled', 'disabled');
}
}
// Merge additional attributes
foreach ($this->additionalAttributes->attributes as $name => $value) {
$attributes = $attributes->with($name, $value);
}
$this->attributes = $attributes;
// Build content
$this->content = $this->icon !== null
? "<span class=\"btn__icon\">{$this->icon}</span><span class=\"btn__text\">{$this->text}</span>"
: $this->text;
$contentHtml = $this->icon !== null
? "<span class=\"btn__icon\">{$this->icon}</span><span class=\"btn__text\">" . htmlspecialchars($this->text) . "</span>"
: htmlspecialchars($this->text);
$element->appendChild(new TextNode($contentHtml));
return $element;
}
// Factory methods for programmatic usage
public static function primary(string $text): self
{
return new self(text: $text, variant: 'primary');
return new self($text, ['variant' => 'primary']);
}
public static function secondary(string $text): self
{
return new self(text: $text, variant: 'secondary');
return new self($text, ['variant' => 'secondary']);
}
public static function danger(string $text): self
{
return new self(text: $text, variant: 'danger');
return new self($text, ['variant' => 'danger']);
}
public static function success(string $text): self
{
return new self(text: $text, variant: 'success');
return new self($text, ['variant' => 'success']);
}
public static function ghost(string $text): self
{
return new self(text: $text, variant: 'ghost');
return new self($text, ['variant' => 'ghost']);
}
public static function link(string $text, string $href): self
{
return new self(text: $text, href: $href);
}
public function asSubmit(): self
{
return new self(
text: $this->text,
type: 'submit',
variant: $this->variant,
size: $this->size,
href: $this->href,
disabled: $this->disabled,
fullWidth: $this->fullWidth,
icon: $this->icon,
additionalAttributes: $this->additionalAttributes
);
}
public function asReset(): self
{
return new self(
text: $this->text,
type: 'reset',
variant: $this->variant,
size: $this->size,
href: $this->href,
disabled: $this->disabled,
fullWidth: $this->fullWidth,
icon: $this->icon,
additionalAttributes: $this->additionalAttributes
);
}
public function withSize(string $size): self
{
return new self(
text: $this->text,
type: $this->type,
variant: $this->variant,
size: $size,
href: $this->href,
disabled: $this->disabled,
fullWidth: $this->fullWidth,
icon: $this->icon,
additionalAttributes: $this->additionalAttributes
);
}
public function withIcon(string $icon): self
{
return new self(
text: $this->text,
type: $this->type,
variant: $this->variant,
size: $this->size,
href: $this->href,
disabled: $this->disabled,
fullWidth: $this->fullWidth,
icon: $icon,
additionalAttributes: $this->additionalAttributes
);
}
public function fullWidth(): self
{
return new self(
text: $this->text,
type: $this->type,
variant: $this->variant,
size: $this->size,
href: $this->href,
disabled: $this->disabled,
fullWidth: true,
icon: $this->icon,
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
return new self($text, ['href' => $href]);
}
}

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class ButtonGroup implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
/**
@@ -41,7 +43,7 @@ final readonly class ButtonGroup implements HtmlElement
}
$buttonsHtml = array_map(
fn(Button $button) => (string) $button,
fn (Button $button) => (string) $button,
$this->buttons
);
$this->content = implode('', $buttonsHtml);

View File

@@ -5,115 +5,63 @@ declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\HtmlElement;
use App\Framework\View\ValueObjects\HtmlTag;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\TagName;
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('card')]
final readonly class Card implements HtmlElement
final readonly class Card implements StaticComponent
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
private string $bodyContent;
private ?string $title;
private ?string $subtitle;
private ?string $footer;
private ?string $imageSrc;
private ?string $imageAlt;
private string $variant;
public function __construct(
string $bodyContent,
public ?string $title = null,
public ?string $subtitle = null,
public ?string $footer = null,
public ?string $imageSrc = null,
public ?string $imageAlt = null,
public string $variant = 'default',
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
string $content = '',
array $attributes = []
) {
$this->tag = new HtmlTag(TagName::DIV);
// Content is the card body
$this->bodyContent = $content;
// 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';
}
public function getRootNode(): Node
{
$div = new ElementNode('div');
// Build CSS classes
$classes = ['card', "card--{$this->variant}"];
$this->attributes = HtmlAttributes::empty()
->withClass(implode(' ', $classes));
$div->setAttribute('class', implode(' ', $classes));
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
// Build complex nested content as HTML string
$contentHtml = $this->buildContent();
$div->appendChild(new TextNode($contentHtml));
$this->content = $this->buildContent($bodyContent);
return $div;
}
public static function create(string $bodyContent): self
{
return new self(bodyContent: $bodyContent);
}
public static function withTitle(string $title, string $bodyContent): self
{
return new self(bodyContent: $bodyContent, title: $title);
}
public static function withImage(string $imageSrc, string $bodyContent, ?string $imageAlt = null): self
{
return new self(bodyContent: $bodyContent, imageSrc: $imageSrc, imageAlt: $imageAlt);
}
public function withSubtitle(string $subtitle): self
{
// Note: Can't access bodyContent from constructor, need to rebuild from content
return new self(
bodyContent: '', // Will be rebuilt in buildContent
title: $this->title,
subtitle: $subtitle,
footer: $this->footer,
imageSrc: $this->imageSrc,
imageAlt: $this->imageAlt,
variant: $this->variant,
additionalAttributes: $this->additionalAttributes
);
}
public function withFooter(string|HtmlElement $footer): self
{
return new self(
bodyContent: '', // Will be rebuilt in buildContent
title: $this->title,
subtitle: $this->subtitle,
footer: (string) $footer,
imageSrc: $this->imageSrc,
imageAlt: $this->imageAlt,
variant: $this->variant,
additionalAttributes: $this->additionalAttributes
);
}
public function withVariant(string $variant): self
{
return new self(
bodyContent: '', // Will be rebuilt in buildContent
title: $this->title,
subtitle: $this->subtitle,
footer: $this->footer,
imageSrc: $this->imageSrc,
imageAlt: $this->imageAlt,
variant: $variant,
additionalAttributes: $this->additionalAttributes
);
}
private function buildContent(string $bodyContent): string
private function buildContent(): string
{
$elements = [];
// Image
if ($this->imageSrc !== null) {
$imgAttrs = HtmlAttributes::empty()
->with('src', $this->imageSrc)
->withClass('card__image');
if ($this->imageAlt !== null) {
$imgAttrs = $imgAttrs->with('alt', $this->imageAlt);
}
$elements[] = StandardHtmlElement::create(TagName::IMG, $imgAttrs);
$altAttr = $this->imageAlt !== null
? ' alt="' . htmlspecialchars($this->imageAlt) . '"'
: '';
$elements[] = '<img src="' . htmlspecialchars($this->imageSrc) . '"' . $altAttr . ' class="card__image" />';
}
// Header (title + subtitle)
@@ -121,53 +69,44 @@ final readonly class Card implements HtmlElement
$headerElements = [];
if ($this->title !== null) {
$headerElements[] = StandardHtmlElement::create(
TagName::H3,
HtmlAttributes::empty()->withClass('card__title'),
$this->title
);
$headerElements[] = '<h3 class="card__title">' . htmlspecialchars($this->title) . '</h3>';
}
if ($this->subtitle !== null) {
$headerElements[] = StandardHtmlElement::create(
TagName::P,
HtmlAttributes::empty()->withClass('card__subtitle'),
$this->subtitle
);
$headerElements[] = '<p class="card__subtitle">' . htmlspecialchars($this->subtitle) . '</p>';
}
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('card__header'),
implode('', array_map('strval', $headerElements))
);
$elements[] = '<div class="card__header">' . implode('', $headerElements) . '</div>';
}
// Body
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('card__body'),
$bodyContent
);
$elements[] = '<div class="card__body">' . htmlspecialchars($this->bodyContent) . '</div>';
// Footer
if ($this->footer !== null) {
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('card__footer'),
$this->footer
);
$elements[] = '<div class="card__footer">' . htmlspecialchars($this->footer) . '</div>';
}
return implode('', array_map('strval', $elements));
return implode('', $elements);
}
public function __toString(): string
// Factory methods for programmatic usage
public static function create(string $bodyContent): self
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
return new self($bodyContent);
}
public static function withTitle(string $title, string $bodyContent): self
{
return new self($bodyContent, ['title' => $title]);
}
public static function withImage(string $imageSrc, string $bodyContent, ?string $imageAlt = null): self
{
$attributes = ['image-src' => $imageSrc];
if ($imageAlt !== null) {
$attributes['image-alt'] = $imageAlt;
}
return new self($bodyContent, $attributes);
}
}

View File

@@ -13,6 +13,7 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class Container implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public function __construct(

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class FormCheckbox implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class FormInput implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class FormRadio implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class FormSelect implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
/**

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class FormTextarea implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class Image implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(

View File

@@ -13,7 +13,9 @@ use App\Framework\View\ValueObjects\TagName;
final readonly class Picture implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
/**

View File

@@ -15,7 +15,8 @@ final readonly class PictureSource
public ?string $type = null,
public ?string $media = null,
public ?string $sizes = null
) {}
) {
}
public static function webp(string $srcset, ?string $media = null): self
{

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Contracts;
use App\Framework\View\Dom\Node;
/**
* StaticComponent Interface
*
* For server-side rendered components without state management.
* Counterpart to LiveComponentContract for static HTML generation.
*
* Constructor Contract:
* - Components MUST accept: __construct(string $content, array $attributes = [])
* - $content: Inner HTML/text content from template
* - $attributes: Key-value pairs from template attributes
*
* Returns HTML Node Tree instead of strings for:
* - Type safety and validation
* - AST manipulation (CSP, sanitization, transformations)
* - Better testability
* - Consistency with DomTemplateParser
*
* Example Implementation:
* ```php
* #[ComponentName('button')]
* final readonly class Button implements StaticComponent
* {
* private string $variant;
* private string $content;
*
* public function __construct(string $content, array $attributes = [])
* {
* $this->content = $content;
* $this->variant = $attributes['variant'] ?? 'primary';
* }
*
* public function getRootNode(): Node
* {
* $button = new ElementNode('button');
* $button->setAttribute('class', "btn btn-{$this->variant}");
* $button->appendChild(new TextNode($this->content));
* return $button;
* }
* }
* ```
*
* Template Usage:
* ```html
* <x-button variant="primary">Click Me</x-button>
* ```
*
* Renders to:
* ```html
* <button class="btn btn-primary">Click Me</button>
* ```
*/
interface StaticComponent
{
/**
* Get the root node of this component's HTML tree
*
* Returns the root node (typically ElementNode or DocumentNode) that represents
* this component's HTML structure. The tree will be rendered to HTML by HtmlRenderer.
*
* @return Node Root node of the component tree
*/
public function getRootNode(): Node;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
final readonly class Attribute
{
public function __construct(
public string $name,
public string $value
) {}
public function __toString(): string
{
if ($this->value === '') {
return $this->name;
}
// Escape quotes in value
$escapedValue = htmlspecialchars($this->value, ENT_QUOTES, 'UTF-8');
return sprintf('%s="%s"', $this->name, $escapedValue);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
final class CommentNode implements Node
{
use NodeTrait {
getTextContent as private getTextContentFromChildren;
}
public function __construct(
private readonly string $content
) {}
public function getNodeType(): NodeType
{
return NodeType::COMMENT;
}
public function getNodeName(): string
{
return '#comment';
}
public function getTextContent(): string
{
return '';
}
public function getContent(): string
{
return $this->content;
}
public function accept(NodeVisitor $visitor): void
{
$visitor->visitComment($this);
}
public function clone(): Node
{
return new self($this->content);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
final class DocumentNode implements Node
{
use NodeTrait;
private ?string $doctype = null;
public function getNodeType(): NodeType
{
return NodeType::DOCUMENT;
}
public function getNodeName(): string
{
return '#document';
}
public function setDoctype(string $doctype): void
{
$this->doctype = $doctype;
}
public function getDoctype(): ?string
{
return $this->doctype;
}
public function getDocumentElement(): ?ElementNode
{
foreach ($this->children as $child) {
if ($child instanceof ElementNode) {
return $child;
}
}
return null;
}
public function accept(NodeVisitor $visitor): void
{
$visitor->visitDocument($this);
foreach ($this->children as $child) {
$child->accept($visitor);
}
}
public function clone(): Node
{
$cloned = new self();
if ($this->doctype !== null) {
$cloned->setDoctype($this->doctype);
}
foreach ($this->children as $child) {
$cloned->appendChild($child->clone());
}
return $cloned;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
final class ElementNode implements Node
{
use NodeTrait;
/** @var array<string, Attribute> */
private array $attributes = [];
/** @var array<string> Self-closing HTML tags */
private const VOID_ELEMENTS = [
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr'
];
public function __construct(
private readonly string $tagName
) {}
public function getNodeType(): NodeType
{
return NodeType::ELEMENT;
}
public function getNodeName(): string
{
return $this->tagName;
}
public function getTagName(): string
{
return $this->tagName;
}
public function setAttribute(string $name, string $value): void
{
$this->attributes[$name] = new Attribute($name, $value);
}
public function getAttribute(string $name): ?string
{
return $this->attributes[$name]->value ?? null;
}
public function hasAttribute(string $name): bool
{
return isset($this->attributes[$name]);
}
public function removeAttribute(string $name): void
{
unset($this->attributes[$name]);
}
/**
* @return array<string, Attribute>
*/
public function getAttributes(): array
{
return $this->attributes;
}
public function isVoidElement(): bool
{
return in_array(strtolower($this->tagName), self::VOID_ELEMENTS, true);
}
public function accept(NodeVisitor $visitor): void
{
$visitor->visitElement($this);
foreach ($this->children as $child) {
$child->accept($visitor);
}
}
public function clone(): Node
{
$cloned = new self($this->tagName);
foreach ($this->attributes as $attribute) {
$cloned->setAttribute($attribute->name, $attribute->value);
}
foreach ($this->children as $child) {
$cloned->appendChild($child->clone());
}
return $cloned;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Exception;
use RuntimeException;
final class ParseException extends RuntimeException
{
public static function invalidHtml(string $message): self
{
return new self("Invalid HTML: {$message}");
}
public static function unclosedTag(string $tagName): self
{
return new self("Unclosed tag: <{$tagName}>");
}
public static function unexpectedClosingTag(string $tagName): self
{
return new self("Unexpected closing tag: </{$tagName}>");
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
interface Node
{
public function getNodeType(): NodeType;
public function getNodeName(): string;
public function getTextContent(): string;
public function getParent(): ?Node;
public function setParent(?Node $parent): void;
/**
* @return array<Node>
*/
public function getChildren(): array;
public function appendChild(Node $child): void;
public function removeChild(Node $child): void;
public function accept(NodeVisitor $visitor): void;
public function clone(): self;
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
trait NodeTrait
{
/** @var array<Node> */
protected array $children = [];
protected ?Node $parent = null;
public function getParent(): ?Node
{
return $this->parent;
}
public function setParent(?Node $parent): void
{
$this->parent = $parent;
}
public function getChildren(): array
{
return $this->children;
}
public function appendChild(Node $child): void
{
$this->children[] = $child;
$child->setParent($this);
}
public function removeChild(Node $child): void
{
$index = array_search($child, $this->children, true);
if ($index !== false) {
array_splice($this->children, $index, 1);
$child->setParent(null);
}
}
public function getTextContent(): string
{
$text = '';
foreach ($this->children as $child) {
$text .= $child->getTextContent();
}
return $text;
}
public function hasChildren(): bool
{
return count($this->children) > 0;
}
public function getFirstChild(): ?Node
{
return $this->children[0] ?? null;
}
public function getLastChild(): ?Node
{
return $this->children[count($this->children) - 1] ?? null;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
enum NodeType: string
{
case DOCUMENT = 'document';
case ELEMENT = 'element';
case TEXT = 'text';
case COMMENT = 'comment';
case CDATA = 'cdata';
case DOCTYPE = 'doctype';
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
interface NodeVisitor
{
public function visitDocument(DocumentNode $node): void;
public function visitElement(ElementNode $node): void;
public function visitText(TextNode $node): void;
public function visitComment(CommentNode $node): void;
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Parser;
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\TextNode;
use App\Framework\View\Lexer\HtmlLexer;
use App\Framework\View\Lexer\Token;
use App\Framework\View\Lexer\TokenType;
final class HtmlParser
{
/** @var array<Token> */
private array $tokens;
private int $position = 0;
/** @var array<ElementNode> */
private array $openElements = [];
public function parse(string $html): DocumentNode
{
$lexer = new HtmlLexer($html);
$this->tokens = $lexer->tokenize();
$this->position = 0;
$this->openElements = [];
$document = new DocumentNode();
while ($this->position < count($this->tokens)) {
$token = $this->current();
match ($token->type) {
TokenType::DOCTYPE => $this->handleDoctype($document, $token),
TokenType::OPEN_TAG_START => $this->handleOpenTag($document, $token),
TokenType::SELF_CLOSING_TAG => $this->handleSelfClosingTag($document, $token),
TokenType::CLOSING_TAG => $this->handleClosingTag($token),
TokenType::CONTENT => $this->handleContent($document, $token),
TokenType::COMMENT => $this->handleComment($document, $token),
TokenType::CDATA => $this->handleContent($document, $token),
default => null,
};
$this->advance();
}
// Handle unclosed tags
while (count($this->openElements) > 0) {
array_pop($this->openElements);
}
return $document;
}
private function handleDoctype(DocumentNode $document, Token $token): void
{
$document->setDoctype(trim($token->content));
}
private function handleOpenTag(DocumentNode $document, Token $token): void
{
$element = $this->parseElement($token->content);
$parent = $this->getCurrentParent($document);
$parent->appendChild($element);
// Push to stack if not a void element
if (!$element->isVoidElement()) {
$this->openElements[] = $element;
}
}
private function handleSelfClosingTag(DocumentNode $document, Token $token): void
{
$element = $this->parseElement($token->content);
$parent = $this->getCurrentParent($document);
$parent->appendChild($element);
// Don't push to stack - it's self-closing
}
private function handleClosingTag(Token $token): void
{
$tagName = $this->extractClosingTagName($token->content);
// Pop elements until we find matching opening tag
while (count($this->openElements) > 0) {
$element = array_pop($this->openElements);
if (strtolower($element->getTagName()) === strtolower($tagName)) {
break;
}
}
}
private function handleContent(DocumentNode $document, Token $token): void
{
$text = $token->content;
// Skip empty text nodes (only whitespace)
if (trim($text) === '' && count($this->openElements) === 0) {
return;
}
$textNode = new TextNode($text);
$parent = $this->getCurrentParent($document);
$parent->appendChild($textNode);
}
private function handleComment(DocumentNode $document, Token $token): void
{
// Extract comment content (remove <!-- and -->)
$content = preg_replace('/^<!--(.*)-->$/s', '$1', $token->content) ?? '';
$commentNode = new CommentNode($content);
$parent = $this->getCurrentParent($document);
$parent->appendChild($commentNode);
}
private function parseElement(string $tagContent): ElementNode
{
// Extract tag name and attributes
// Example: <div class="foo" id="bar">
preg_match('/<([a-z][a-z0-9-]*)/i', $tagContent, $matches);
$tagName = $matches[1] ?? 'div';
$element = new ElementNode($tagName);
// Parse attributes
$attributesString = substr($tagContent, strlen($tagName) + 1);
$attributesString = rtrim($attributesString, '/>');
$attributesString = trim($attributesString);
if ($attributesString !== '') {
$this->parseAttributes($element, $attributesString);
}
return $element;
}
private function parseAttributes(ElementNode $element, string $attributesString): void
{
// 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);
foreach ($matches as $match) {
$name = $match[1];
$value = $match[2] ?? $match[3] ?? $match[4] ?? '';
$element->setAttribute($name, $value);
}
}
private function extractClosingTagName(string $closingTag): string
{
// Extract "div" from "</div>"
preg_match('/<\/([a-z][a-z0-9-]*)/i', $closingTag, $matches);
return $matches[1] ?? '';
}
private function getCurrentParent(DocumentNode $document): Node
{
return $this->openElements[count($this->openElements) - 1] ?? $document;
}
private function current(): Token
{
return $this->tokens[$this->position];
}
private function advance(): void
{
$this->position++;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Query;
use App\Framework\View\Dom\ElementNode;
use App\Framework\View\Dom\Node;
final class QuerySelector
{
/**
* Find first element matching selector
*/
public function querySelector(Node $root, string $selector): ?ElementNode
{
$results = $this->querySelectorAll($root, $selector);
return $results[0] ?? null;
}
/**
* Find all elements matching selector
*
* @return array<ElementNode>
*/
public function querySelectorAll(Node $root, string $selector): array
{
$results = [];
$this->traverse($root, $selector, $results);
return $results;
}
/**
* @param array<ElementNode> $results
*/
private function traverse(Node $node, string $selector, array &$results): void
{
if ($node instanceof ElementNode) {
if ($this->matches($node, $selector)) {
$results[] = $node;
}
}
foreach ($node->getChildren() as $child) {
$this->traverse($child, $selector, $results);
}
}
private function matches(ElementNode $element, string $selector): bool
{
// Simple selector implementation
// Supports: tagname, .class, #id, [attr], [attr=value]
// Tag name selector
if (!str_contains($selector, '.')
&& !str_contains($selector, '#')
&& !str_contains($selector, '[')) {
return strtolower($element->getTagName()) === strtolower($selector);
}
// Class selector
if (str_starts_with($selector, '.')) {
$className = substr($selector, 1);
$classes = explode(' ', $element->getAttribute('class') ?? '');
return in_array($className, $classes, true);
}
// ID selector
if (str_starts_with($selector, '#')) {
$id = substr($selector, 1);
return $element->getAttribute('id') === $id;
}
// Attribute selector
if (preg_match('/\[([a-z-]+)(?:=(["\'])?(.*?)\2)?\]/i', $selector, $matches)) {
$attrName = $matches[1];
if (!isset($matches[3])) {
// [attr] - just check existence
return $element->hasAttribute($attrName);
}
// [attr=value]
$attrValue = $matches[3];
return $element->getAttribute($attrName) === $attrValue;
}
return false;
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Renderer;
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\TextNode;
final class HtmlRenderer
{
private bool $pretty = false;
private int $indentLevel = 0;
private string $indentString = ' ';
public function __construct(bool $pretty = false)
{
$this->pretty = $pretty;
}
public function render(Node $node): string
{
$this->indentLevel = 0;
return $this->renderNode($node);
}
private function renderNode(Node $node): string
{
return match ($node::class) {
DocumentNode::class => $this->renderDocument($node),
ElementNode::class => $this->renderElement($node),
TextNode::class => $this->renderText($node),
CommentNode::class => $this->renderComment($node),
default => '',
};
}
private function renderDocument(DocumentNode $node): string
{
$html = '';
if ($node->getDoctype() !== null) {
$html .= $node->getDoctype();
if ($this->pretty) {
$html .= "\n";
}
}
foreach ($node->getChildren() as $child) {
$html .= $this->renderNode($child);
}
return $html;
}
private function renderElement(ElementNode $node): string
{
$html = '';
if ($this->pretty) {
$html .= str_repeat($this->indentString, $this->indentLevel);
}
$html .= '<' . $node->getTagName();
// Render attributes
foreach ($node->getAttributes() as $attribute) {
$html .= ' ' . $attribute;
}
// Self-closing void elements
if ($node->isVoidElement()) {
$html .= ' />';
if ($this->pretty) {
$html .= "\n";
}
return $html;
}
$html .= '>';
// Render children
if ($node->hasChildren()) {
if ($this->pretty) {
$html .= "\n";
$this->indentLevel++;
}
foreach ($node->getChildren() as $child) {
$html .= $this->renderNode($child);
}
if ($this->pretty) {
$this->indentLevel--;
$html .= str_repeat($this->indentString, $this->indentLevel);
}
}
// Closing tag
$html .= '</' . $node->getTagName() . '>';
if ($this->pretty) {
$html .= "\n";
}
return $html;
}
private function renderText(TextNode $node): string
{
$text = $node->getTextContent();
if ($this->pretty) {
$text = trim($text);
if ($text === '') {
return '';
}
return str_repeat($this->indentString, $this->indentLevel) . $text . "\n";
}
return $text;
}
private function renderComment(CommentNode $node): string
{
$html = '';
if ($this->pretty) {
$html .= str_repeat($this->indentString, $this->indentLevel);
}
$html .= '<!--' . $node->getContent() . '-->';
if ($this->pretty) {
$html .= "\n";
}
return $html;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom;
final class TextNode implements Node
{
use NodeTrait {
getTextContent as private getTextContentFromChildren;
}
public function __construct(
private string $text
) {}
public function getNodeType(): NodeType
{
return NodeType::TEXT;
}
public function getNodeName(): string
{
return '#text';
}
public function getTextContent(): string
{
return $this->text;
}
public function setText(string $text): void
{
$this->text = $text;
}
public function accept(NodeVisitor $visitor): void
{
$visitor->visitText($this);
}
public function clone(): Node
{
return new self($this->text);
}
}

View File

@@ -0,0 +1,165 @@
<?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;
use App\Framework\Vite\ViteService;
/**
* AssetInjectorTransformer - Injects Vite assets into HTML head
*
* Automatically detects development vs production mode and injects
* appropriate script and stylesheet tags using ViteService.
*
* Framework Pattern: readonly class, AST-based transformation
*/
final readonly class AssetInjectorTransformer implements AstTransformer
{
public function __construct(
private ViteService $viteService
) {}
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Skip if this is a partial render (e.g. component)
if ($context->isPartial ?? false) {
return $document;
}
// Find head element
$head = $this->findHeadElement($document);
if (!$head) {
return $document;
}
// Generate Vite tags (dev mode with HMR or production from manifest)
$viteTags = $this->viteService->getTags();
// Parse and inject tags
$this->injectViteTags($head, $viteTags);
return $document;
}
/**
* Find the head element in the document
*/
private function findHeadElement(DocumentNode $document): ?ElementNode
{
foreach ($document->getChildren() as $child) {
if ($child instanceof ElementNode && strtolower($child->getTagName()) === 'html') {
// Found html element, look for head inside
foreach ($child->getChildren() as $htmlChild) {
if ($htmlChild instanceof ElementNode && strtolower($htmlChild->getTagName()) === 'head') {
return $htmlChild;
}
}
} elseif ($child instanceof ElementNode && strtolower($child->getTagName()) === 'head') {
// Direct head element
return $child;
}
}
return null;
}
/**
* Parse Vite tags HTML and inject into head
*/
private function injectViteTags(ElementNode $head, string $viteTags): void
{
if (empty($viteTags)) {
return;
}
// Parse each line and inject appropriately
$lines = explode("\n", $viteTags);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '<!--')) {
continue;
}
// Detect tag type and inject
if (str_contains($line, '<link') && str_contains($line, 'stylesheet')) {
$this->injectStylesheet($head, $line);
} elseif (str_contains($line, '<script')) {
$this->injectScript($head, $line);
} elseif (str_contains($line, '<link') && str_contains($line, 'preload')) {
$this->injectPreload($head, $line);
}
}
}
/**
* Extract href from stylesheet tag and inject
*/
private function injectStylesheet(ElementNode $head, string $tag): void
{
if (preg_match('/href="([^"]+)"/', $tag, $matches)) {
$link = new ElementNode('link');
$link->setAttribute('rel', 'stylesheet');
$link->setAttribute('href', $matches[1]);
$head->appendChild($link);
}
}
/**
* Extract src from script tag and inject
*/
private function injectScript(ElementNode $head, string $tag): void
{
if (preg_match('/src="([^"]+)"/', $tag, $matches)) {
$script = new ElementNode('script');
$script->setAttribute('src', $matches[1]);
// Extract type="module"
if (str_contains($tag, 'type="module"')) {
$script->setAttribute('type', 'module');
}
// Extract nonce
if (preg_match('/nonce="([^"]+)"/', $tag, $nonceMatches)) {
$script->setAttribute('nonce', $nonceMatches[1]);
}
// Extract integrity
if (preg_match('/integrity="([^"]+)"/', $tag, $integrityMatches)) {
$script->setAttribute('integrity', $integrityMatches[1]);
}
$head->appendChild($script);
}
}
/**
* Extract href from preload tag and inject
*/
private function injectPreload(ElementNode $head, string $tag): void
{
if (preg_match('/href="([^"]+)"/', $tag, $matches)) {
$link = new ElementNode('link');
$link->setAttribute('rel', 'preload');
$link->setAttribute('href', $matches[1]);
// Extract 'as' attribute
if (preg_match('/as="([^"]+)"/', $tag, $asMatches)) {
$link->setAttribute('as', $asMatches[1]);
} else {
$link->setAttribute('as', 'script');
}
$head->appendChild($link);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Transformer;
use App\Framework\Template\Processing\AstTransformer;
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\RenderContext;
/**
* CommentStripTransformer - Removes HTML comments from AST
*
* Recursively removes all CommentNode instances from the AST.
*
* Before: <div><!-- comment --><p>text</p></div>
* After: <div><p>text</p></div>
*
* Framework Pattern: readonly class, AST-based transformation
*/
final readonly class CommentStripTransformer implements AstTransformer
{
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Process all children recursively
$this->removeComments($document);
return $document;
}
/**
* Recursively remove all comment nodes
*/
private function removeComments(Node $node): void
{
if (! $node instanceof ElementNode && ! $node instanceof DocumentNode) {
return;
}
// Get children via public API
$children = $node->getChildren();
// Remove CommentNode children
foreach ($children as $child) {
if ($child instanceof CommentNode) {
$node->removeChild($child);
}
}
// Recurse into remaining children
foreach ($node->getChildren() as $child) {
$this->removeComments($child);
}
}
}

View File

@@ -0,0 +1,136 @@
<?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\Dom\TextNode;
use App\Framework\View\RenderContext;
/**
* 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)
*
* 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 transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Find all form elements and add honeypot fields
$this->processForms($document);
return $document;
}
/**
* Recursively find and process form elements
*/
private function processForms(Node $node): void
{
if ($node instanceof ElementNode) {
// Check if this is a form element
if (strtolower($node->getTagName()) === 'form') {
$this->addHoneypot($node);
$this->addTimeValidation($node);
}
}
// Recurse into children for both ElementNode and DocumentNode
if ($node instanceof ElementNode || $node instanceof DocumentNode) {
foreach ($node->getChildren() as $child) {
$this->processForms($child);
}
}
}
/**
* Add honeypot field to form
*/
private function addHoneypot(ElementNode $form): void
{
$honeypotName = self::HONEYPOT_NAMES[array_rand(self::HONEYPOT_NAMES)];
// Create hidden container for honeypot
$container = new ElementNode('div');
$container->setAttribute('style', 'position:absolute;left:-9999px;visibility:hidden;');
$container->setAttribute('aria-hidden', 'true');
// Create label (looks realistic to bots)
$label = new ElementNode('label');
$label->setAttribute('for', $honeypotName);
$label->appendChild(new TextNode('Website (optional)'));
// Create honeypot input
$honeypot = new ElementNode('input');
$honeypot->setAttribute('type', 'text');
$honeypot->setAttribute('name', $honeypotName);
$honeypot->setAttribute('id', $honeypotName);
$honeypot->setAttribute('autocomplete', 'off');
$honeypot->setAttribute('tabindex', '-1');
// Assemble container
$container->appendChild($label);
$container->appendChild($honeypot);
// Create hidden field with honeypot name for server-side validation
$nameField = new ElementNode('input');
$nameField->setAttribute('type', 'hidden');
$nameField->setAttribute('name', '_honeypot_name');
$nameField->setAttribute('value', $honeypotName);
// Insert at beginning of form (before other fields)
$this->prependChild($form, $container);
$this->prependChild($form, $nameField);
}
/**
* Add time validation field to form
*/
private function addTimeValidation(ElementNode $form): void
{
$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);
}
/**
* 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

@@ -2,43 +2,86 @@
declare(strict_types=1);
namespace App\Framework\View\Processors;
namespace App\Framework\View\Dom\Transformer;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
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;
final readonly class IfProcessor implements DomProcessor
/**
* IfTransformer - Conditional rendering for AST elements
*
* Processes 'if' and 'condition' attributes on elements:
* - Evaluates condition expressions against context data
* - Removes element if condition is falsy
* - Removes attribute if condition is truthy
*
* Supports:
* - Simple properties: if="user.isAdmin"
* - Comparisons: if="count > 5", if="status == 'active'"
* - Logical operators: if="user.isAdmin && user.isVerified"
* - Negation: if="!user.isBanned"
* - Array properties: if="items.length > 0"
* - Method calls: if="collection.isEmpty()"
*
* Framework Pattern: readonly class, AST-based transformation
*/
final readonly class IfTransformer implements AstTransformer
{
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Handle both 'if' and 'condition' attributes
$this->processIfAttribute($dom, $context, 'if');
$this->processIfAttribute($dom, $context, 'condition');
// Process both 'if' and 'condition' attributes
$this->processIfAttribute($document, $context, 'if');
$this->processIfAttribute($document, $context, 'condition');
return $dom;
}
private function processIfAttribute(DomWrapper $dom, RenderContext $context, string $attributeName): void
{
$dom->getElementsByAttribute($attributeName)->forEach(function ($node) use ($context, $attributeName) {
$condition = $node->getAttribute($attributeName);
// Evaluate the condition expression
$result = $this->evaluateCondition($context->data, $condition);
// Entferne, wenn die Bedingung nicht erfüllt ist
if (! $result) {
$node->parentNode?->removeChild($node);
} else {
// Entferne Attribut bei Erfolg
$node->removeAttribute($attributeName);
}
});
return $document;
}
/**
* Evaluates a condition expression with support for operators
* Process elements with if/condition attribute
*/
private function processIfAttribute(Node $node, RenderContext $context, string $attributeName): void
{
if (! $node instanceof ElementNode && ! $node instanceof DocumentNode) {
return;
}
// 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);
if (! $result) {
// Mark node for removal by clearing children and setting marker
foreach ($node->getChildren() as $child) {
$node->removeChild($child);
}
$node->setAttribute('__remove__', 'true');
return; // Don't process children of removed node
}
// Remove attribute if condition passed
$node->removeAttribute($attributeName);
}
// Get children via public API
$children = $node->getChildren();
// Process and filter children
foreach ($children as $child) {
$this->processIfAttribute($child, $context, $attributeName);
// Remove children marked for removal
if ($child instanceof ElementNode && $child->hasAttribute('__remove__')) {
$node->removeChild($child);
}
}
}
/**
* Evaluates condition expression with support for operators
*/
private function evaluateCondition(array $data, string $condition): bool
{
@@ -52,7 +95,6 @@ final readonly class IfProcessor implements DomProcessor
return false;
}
}
return true;
}
@@ -63,14 +105,12 @@ final readonly class IfProcessor implements DomProcessor
return true;
}
}
return false;
}
// Handle negation (!)
if (str_starts_with($condition, '!')) {
$negatedCondition = trim(substr($condition, 1));
return ! $this->evaluateCondition($data, $negatedCondition);
}
@@ -93,14 +133,13 @@ final readonly class IfProcessor implements DomProcessor
}
}
// Simple property evaluation (fallback to original behavior)
// Simple property evaluation
$value = $this->resolveValue($data, $condition);
return $this->isTruthy($value);
}
/**
* Parse a value from expression (can be a property path, string literal, or number)
* Parse value from expression (property path, string literal, or number)
*/
private function parseValue(array $data, string $expr): mixed
{
@@ -128,7 +167,7 @@ final readonly class IfProcessor implements DomProcessor
return null;
}
// Property path (including .length and method calls)
// Property path
return $this->resolveComplexValue($data, $expr);
}
@@ -152,9 +191,9 @@ final readonly class IfProcessor implements DomProcessor
return null;
}
// Handle .length property for arrays and collections
// Handle .length property for arrays
if (str_ends_with($expr, '.length')) {
$basePath = substr($expr, 0, -7); // Remove '.length'
$basePath = substr($expr, 0, -7);
$value = $this->resolveValue($data, $basePath);
if (is_array($value)) {
@@ -195,6 +234,9 @@ final readonly class IfProcessor implements DomProcessor
return $value;
}
/**
* Check if value is truthy
*/
private function isTruthy(mixed $value): bool
{
if (is_bool($value)) {

View File

@@ -0,0 +1,292 @@
<?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\Dom\Parser\HtmlParser;
use App\Framework\View\Dom\TextNode;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\Processors\PlaceholderReplacer;
use App\Framework\View\RenderContext;
/**
* LayoutTagTransformer - Processes layout tags and inserts content into layout slots
*
* Finds <layout src="name"> or <layout name="name"> tags, loads the layout template,
* and inserts the page content into the layout's <main> slot.
*
* Framework Pattern: readonly class, AST-based transformation
*/
final readonly class LayoutTagTransformer implements AstTransformer
{
public function __construct(
private TemplateLoader $loader,
private HtmlParser $parser,
private PlaceholderReplacer $placeholderReplacer
) {}
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Skip layout processing for partials
if ($context->isPartial) {
return $this->removeLayoutTag($document);
}
// Find layout tag
$layoutTag = $this->findLayoutTag($document);
if (!$layoutTag) {
return $document;
}
// Apply layout
return $this->applyLayout($layoutTag, $document, $context);
}
/**
* Find layout tag in document
*/
private function findLayoutTag(DocumentNode $document): ?ElementNode
{
return $this->searchForLayoutTag($document);
}
/**
* Recursively search for layout tag
*/
private function searchForLayoutTag(Node $node): ?ElementNode
{
if ($node instanceof ElementNode && strtolower($node->getTagName()) === 'layout') {
// Check for name or src attribute
if ($node->getAttribute('name') !== null || $node->getAttribute('src') !== null) {
return $node;
}
}
// Search in children
if ($node instanceof ElementNode || $node instanceof DocumentNode) {
foreach ($node->getChildren() as $child) {
$result = $this->searchForLayoutTag($child);
if ($result !== null) {
return $result;
}
}
}
return null;
}
/**
* Apply layout to document
*/
private function applyLayout(ElementNode $layoutTag, DocumentNode $document, RenderContext $context): DocumentNode
{
// Get layout file name (support both 'name' and 'src' attributes)
$layoutFile = $layoutTag->getAttribute('name') ?? $layoutTag->getAttribute('src') ?? null;
if (!$layoutFile) {
return $document;
}
// Load layout template
$layoutPath = $this->loader->getTemplatePath($layoutFile);
$layoutContent = file_get_contents($layoutPath);
// Process basic placeholders in layout (not components or loops yet)
$layoutContext = new RenderContext(
template: $layoutFile,
metaData: $context->metaData,
data: $context->data,
isPartial: false
);
$processedLayoutHtml = $this->placeholderReplacer->process($layoutContent, $layoutContext);
// 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);
}
return $layoutDocument;
}
/**
* Find main slot element in layout
*/
private function findMainSlot(DocumentNode $document): ?ElementNode
{
return $this->searchForElement($document, 'main');
}
/**
* Recursively search for element by tag name
*/
private function searchForElement(Node $node, string $tagName): ?ElementNode
{
if ($node instanceof ElementNode && strtolower($node->getTagName()) === strtolower($tagName)) {
return $node;
}
// Search in children
if ($node instanceof ElementNode || $node instanceof DocumentNode) {
foreach ($node->getChildren() as $child) {
$result = $this->searchForElement($child, $tagName);
if ($result !== null) {
return $result;
}
}
}
return null;
}
/**
* Collect content to insert into layout slot
*
* @return Node[]
*/
private function collectContent(ElementNode $layoutTag, DocumentNode $document): array
{
$children = $layoutTag->getChildren();
// If layout tag has children, use those
if (!empty($children)) {
return $children;
}
// Otherwise, collect all content after layout tag (self-closing tag syntax)
return $this->collectContentAfterLayoutTag($layoutTag, $document);
}
/**
* Collect all nodes after the layout tag
*
* @return Node[]
*/
private function collectContentAfterLayoutTag(ElementNode $layoutTag, DocumentNode $document): array
{
$content = [];
$foundLayoutTag = false;
// Find the layout tag and collect everything after it
$this->collectNodesAfter($document, $layoutTag, $foundLayoutTag, $content);
return $content;
}
/**
* Recursively collect nodes after a specific node
*/
private function collectNodesAfter(Node $node, ElementNode $target, bool &$found, array &$content): void
{
if ($node === $target) {
$found = true;
return;
}
if ($found && $node !== $target) {
// Collect this node if we're past the target (sibling-level only)
if (!($node instanceof TextNode && trim($node->getTextContent()) === '')) {
$content[] = $node;
}
// Don't recurse into collected nodes - we collect them as-is
return;
}
// Recurse into children ONLY if we haven't found the target yet
if (!$found && ($node instanceof ElementNode || $node instanceof DocumentNode)) {
foreach ($node->getChildren() as $child) {
$this->collectNodesAfter($child, $target, $found, $content);
}
}
}
/**
* Remove layout tag (for partials)
*/
private function removeLayoutTag(DocumentNode $document): DocumentNode
{
$layoutTag = $this->findLayoutTag($document);
if (!$layoutTag) {
return $document;
}
// Find parent of layout tag and replace layout tag with its children
$this->replaceNodeWithChildren($document, $layoutTag);
return $document;
}
/**
* Replace a node with its children in the tree
*/
private function replaceNodeWithChildren(Node $parent, ElementNode $target): bool
{
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
$children = $parent->getChildren();
$found = false;
foreach ($children as $index => $child) {
if ($child === $target) {
// Found the target - replace with its children
$targetChildren = $target->getChildren();
// Remove target
$parent->removeChild($target);
// Insert target's children at the same position
foreach ($targetChildren as $targetChild) {
$parent->appendChild($targetChild);
}
$found = true;
break;
}
// Recurse
if ($this->replaceNodeWithChildren($child, $target)) {
$found = true;
break;
}
}
return $found;
}
return false;
}
/**
* Clear all children from a node
*/
private function clearChildren(ElementNode $node): void
{
$children = $node->getChildren();
foreach ($children as $child) {
$node->removeChild($child);
}
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Transformer;
use App\Framework\Meta\OpenGraphType;
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\RenderContext;
/**
* MetaManipulatorTransformer - Manipulates meta tags in HTML head
*
* Sets title, description, and OpenGraph meta tags based on RenderContext.
*
* Framework Pattern: readonly class, AST-based transformation
*/
final readonly class MetaManipulatorTransformer implements AstTransformer
{
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
$metaData = $context->metaData;
// Find head element
$head = $this->findHeadElement($document);
if (!$head) {
return $document;
}
// Set title
if (!empty($metaData->title)) {
$this->setTitle($head, $metaData->title);
}
// Set description meta tag
if (!empty($metaData->description)) {
$this->setMetaTag($head, 'name', 'description', $metaData->description);
}
// Set OpenGraph type
$ogType = match($metaData->openGraph->type) {
OpenGraphType::WEBSITE => 'website',
OpenGraphType::ARTICLE => 'article',
default => 'website',
};
$this->setMetaTag($head, 'property', 'og:type', $ogType);
return $document;
}
/**
* Find the head element in the document
*/
private function findHeadElement(DocumentNode $document): ?ElementNode
{
foreach ($document->getChildren() as $child) {
if ($child instanceof ElementNode && strtolower($child->getTagName()) === 'html') {
// Found html element, look for head inside
foreach ($child->getChildren() as $htmlChild) {
if ($htmlChild instanceof ElementNode && strtolower($htmlChild->getTagName()) === 'head') {
return $htmlChild;
}
}
} elseif ($child instanceof ElementNode && strtolower($child->getTagName()) === 'head') {
// Direct head element
return $child;
}
}
return null;
}
/**
* Set or update title element
*/
private function setTitle(ElementNode $head, string $title): void
{
$titleText = $title . " | Michael Schiemer";
// Find existing title element
foreach ($head->getChildren() as $child) {
if ($child instanceof ElementNode && strtolower($child->getTagName()) === 'title') {
// Update existing title - clear children and add new text
foreach ($child->getChildren() as $titleChild) {
$child->removeChild($titleChild);
}
$child->appendChild(new TextNode($titleText));
return;
}
}
// Create new title element
$titleElement = new ElementNode('title');
$titleElement->appendChild(new TextNode($titleText));
$head->appendChild($titleElement);
}
/**
* Set or update meta tag
*/
private function setMetaTag(ElementNode $head, string $attributeType, string $attributeValue, string $content): void
{
// Find existing meta tag
foreach ($head->getChildren() as $child) {
if ($child instanceof ElementNode && strtolower($child->getTagName()) === 'meta') {
if ($child->getAttribute($attributeType) === $attributeValue) {
// Update existing meta tag
$child->setAttribute('content', $content);
return;
}
}
}
// Create new meta tag
$metaElement = new ElementNode('meta');
$metaElement->setAttribute($attributeType, $attributeValue);
$metaElement->setAttribute('content', $content);
$head->appendChild($metaElement);
}
}

View File

@@ -0,0 +1,72 @@
<?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\Dom\TextNode;
use App\Framework\View\RenderContext;
/**
* WhitespaceCleanupTransformer - Removes empty text nodes from AST
*
* Recursively removes TextNode instances that contain only whitespace.
* This includes empty lines, spaces-only nodes, etc.
*
* Before: <div> \n <p>text</p> \n </div>
* After: <div><p>text</p></div>
*
* Framework Pattern: readonly class, AST-based transformation
*/
final readonly class WhitespaceCleanupTransformer implements AstTransformer
{
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Process all children recursively
$this->removeEmptyTextNodes($document);
return $document;
}
/**
* Recursively remove text nodes containing only whitespace
*/
private function removeEmptyTextNodes(Node $node): void
{
if (! $node instanceof ElementNode && ! $node instanceof DocumentNode) {
return;
}
// Get children via public API
$children = $node->getChildren();
// Remove empty TextNode children
foreach ($children as $child) {
if ($this->isEmptyTextNode($child)) {
$node->removeChild($child);
}
}
// Recurse into remaining children
foreach ($node->getChildren() as $child) {
$this->removeEmptyTextNodes($child);
}
}
/**
* Check if node is a TextNode containing only whitespace
*/
private function isEmptyTextNode(Node $node): bool
{
if (! $node instanceof TextNode) {
return false;
}
// Check if content is only whitespace via public API
return preg_match('/^\s*$/', $node->getTextContent()) === 1;
}
}

View File

@@ -0,0 +1,417 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Transformer;
use App\Framework\LiveComponents\Contracts\ComponentRegistryInterface;
use App\Framework\LiveComponents\Performance\ComponentMetadataCacheInterface;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Template\Processing\AstTransformer;
use App\Framework\View\Contracts\StaticComponent;
use App\Framework\View\Dom\CommentNode;
use App\Framework\View\Dom\DocumentNode;
use App\Framework\View\Dom\ElementNode;
use App\Framework\View\Dom\NodeVisitor;
use App\Framework\View\Dom\Parser\HtmlParser;
use App\Framework\View\Dom\TextNode;
use App\Framework\View\RenderContext;
use App\Framework\View\StaticComponentRenderer;
use ReflectionObject;
/**
* XComponentTransformer - AST-based component transformation
*
* Handles BOTH LiveComponents AND static HTML components with <x-*> syntax
* using AST instead of DOM manipulation for better performance and reliability.
*/
final class XComponentTransformer implements NodeVisitor, AstTransformer
{
/** @var array<ElementNode> Elements to transform */
private array $xComponents = [];
public function __construct(
private readonly ComponentRegistryInterface $componentRegistry,
private readonly StaticComponentRenderer $staticComponentRenderer,
private readonly ComponentMetadataCacheInterface $metadataCache,
private readonly HtmlParser $parser
) {
}
/**
* Transform all x-components in the document (AstTransformer interface)
*/
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Reset state
$this->xComponents = [];
// Phase 1: Find all x-components (visitor pattern)
$document->accept($this);
// Phase 2: Transform each x-component
foreach ($this->xComponents as $xComponent) {
try {
$this->transformXComponent($xComponent);
} catch (\Throwable $e) {
$this->handleTransformError($xComponent, $e);
}
}
return $document;
}
public function visitDocument(DocumentNode $node): void
{
// Continue traversal to children
}
public function visitElement(ElementNode $node): void
{
// Check if this is an x-component that needs transformation
if ($this->isXComponent($node)) {
$this->xComponents[] = $node;
}
}
public function visitText(TextNode $node): void
{
// Nothing to do with text nodes
}
public function visitComment(CommentNode $node): void
{
// Nothing to do with comments
}
private function isXComponent(ElementNode $node): bool
{
return str_starts_with(strtolower($node->getTagName()), 'x-');
}
/**
* Transform single x-component with auto-detection
*/
private function transformXComponent(ElementNode $element): void
{
// Extract component name: <x-datatable> → "datatable"
$componentName = $this->extractComponentName($element);
// Check if component is registered
if (!$this->componentRegistry->isRegistered($componentName)) {
throw new \InvalidArgumentException(
$this->generateComponentNotFoundError($componentName)
);
}
// Get component class name to determine type
$componentClass = $this->componentRegistry->getClassName($componentName);
if ($componentClass === null) {
throw new \InvalidArgumentException(
"Component '{$componentName}' is registered but class name could not be resolved"
);
}
// 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);
$this->replaceWithHtml($element, $html);
} else {
// Process as LiveComponent (Interactive/Stateful)
$html = $this->renderLiveComponent($element, $componentName);
$this->replaceWithHtml($element, $html);
}
}
/**
* Render LiveComponent to HTML
*/
private function renderLiveComponent(ElementNode $element, string $componentName): string
{
// Extract props from attributes
$props = $this->extractProps($element);
// TODO: Re-enable validation once ComponentMetadata properly extracts constructor parameters
// $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);
// Resolve component via ComponentRegistry (pass props array directly)
$component = $this->componentRegistry->resolve($componentId, $props);
// Render component with wrapper (for initial page load)
return $this->componentRegistry->renderWithWrapper($component);
}
/**
* Render StaticComponent to HTML
*
* @param class-string<StaticComponent> $componentClass
*/
private function renderStaticComponent(ElementNode $element, string $componentClass): string
{
// Get element content and attributes
$content = $element->getTextContent();
$attributes = $this->extractAttributesForStaticComponent($element);
// Render via StaticComponentRenderer
return $this->staticComponentRenderer->render(
$componentClass,
$content,
$attributes
);
}
/**
* Replace element with parsed HTML nodes
*/
private function replaceWithHtml(ElementNode $element, string $html): void
{
// Parse HTML to AST
$componentDocument = $this->parser->parse($html);
// Get parent node
$parent = $element->getParent();
if (!$parent) {
return; // No parent, can't replace
}
// Access parent's children array directly via reflection
// This is needed because NodeTrait doesn't expose insertAt() method
// Note: setAccessible() deprecated in PHP 8.5, properties are accessible by default
$parentReflection = new ReflectionObject($parent);
$childrenProperty = $parentReflection->getProperty('children');
$children = $childrenProperty->getValue($parent);
$position = array_search($element, $children, true);
if ($position === false) {
return; // Element isn't found in the parent
}
// Remove original element's parent reference
$element->setParent(null);
// Build new children array with replacement
$newChildren = [];
// Copy children before position
for ($i = 0; $i < $position; $i++) {
$newChildren[] = $children[$i];
}
// Insert all children of componentDocument
foreach ($componentDocument->getChildren() as $newChild) {
$clonedChild = $newChild->clone();
$clonedChild->setParent($parent);
$newChildren[] = $clonedChild;
}
// Copy remaining children after position
for ($i = $position + 1; $i < count($children); $i++) {
$newChildren[] = $children[$i];
}
// Update parent's children array
$childrenProperty->setValue($parent, $newChildren);
}
/**
* Extract component name from tag: <x-datatable> → "datatable"
*/
private function extractComponentName(ElementNode $element): string
{
$tagName = strtolower($element->getTagName());
if (!str_starts_with($tagName, 'x-')) {
throw new \InvalidArgumentException(
"Invalid x-component tag: '{$tagName}' (must start with 'x-')"
);
}
return substr($tagName, 2); // Remove "x-" prefix
}
/**
* Extract props from attributes with type coercion (for LiveComponents)
*
* @return array<string, mixed>
*/
private function extractProps(ElementNode $element): array
{
$props = [];
foreach ($element->getAttributes() as $attr) {
// Type coercion: "123" → 123, "true" → true, "[1,2]" → [1,2]
$props[$attr->name] = $this->coerceType($attr->value);
}
return $props;
}
/**
* Extract attributes as strings (for StaticComponents)
*
* @return array<string, string>
*/
private function extractAttributesForStaticComponent(ElementNode $element): array
{
$attributes = [];
foreach ($element->getAttributes() as $attr) {
$attributes[$attr->name] = $attr->value;
}
return $attributes;
}
/**
* Type coercion for prop values (LiveComponents only)
*/
private function coerceType(string $value): mixed
{
// Boolean literals
if ($value === 'true') {
return true;
}
if ($value === 'false') {
return false;
}
// Null literal
if ($value === 'null') {
return null;
}
// Numeric values
if (is_numeric($value)) {
return str_contains($value, '.') ? (float)$value : (int)$value;
}
// JSON arrays or objects: "[1,2,3]" or '{"key":"value"}'
if (str_starts_with($value, '[') || str_starts_with($value, '{')) {
try {
$decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
return $decoded;
} catch (\JsonException) {
// Not valid JSON, treat as string
}
}
// Fallback: string
return $value;
}
/**
* Validate props against ComponentMetadata (LiveComponents only)
*
* @param array<string, mixed> $props
*/
private function validateLiveComponentProps(string $componentName, array $props): void
{
// Get component class name from registry
$className = $this->componentRegistry->getClassName($componentName);
if ($className === null) {
return; // Component not found - will be caught in transformXComponent
}
// Get metadata from cache
$metadata = $this->metadataCache->get($className);
// Validate each prop exists in component
foreach ($props as $propName => $value) {
// Skip 'id' - it's used for ComponentId, not a component property
if ($propName === 'id') {
continue;
}
if (!$metadata->hasProperty($propName)) {
throw new \InvalidArgumentException(
"LiveComponent '{$componentName}' has no property '{$propName}'. " .
"Available properties: " . implode(', ', $this->getPropertyNames($metadata))
);
}
}
}
/**
* Get property names from metadata
*
* @param object{properties?: array<string, mixed>} $metadata
* @return array<int, string>
*/
private function getPropertyNames(object $metadata): array
{
$properties = $metadata->properties ?? [];
return array_keys($properties);
}
/**
* Generate unique instance ID for component
*/
private function generateInstanceId(string $componentName): string
{
return $componentName . '-' . bin2hex(random_bytes(4));
}
/**
* Generate helpful error message when component not found
*/
private function generateComponentNotFoundError(string $componentName): string
{
$allComponents = $this->componentRegistry->getAllComponentNames();
$message = "Unknown component: <x-{$componentName}>\n\n";
if (!empty($allComponents)) {
$message .= "Available components: " . implode(', ', $allComponents) . "\n";
} else {
$message .= "No components registered. Check #[LiveComponent] and #[ComponentName] attributes.";
}
return $message;
}
/**
* Handle transformation errors
*
* In development: Replace with error message
* In production: Remove element
*/
private function handleTransformError(ElementNode $element, \Throwable $e): void
{
$isDebug = ($_ENV['APP_ENV'] ?? 'production') === 'development';
if ($isDebug) {
// Create error node in place of component
$errorHtml = sprintf(
'<div style="border:2px solid red;padding:1rem;background:#fee;color:#c00;">' .
'<strong>XComponentTransformer Error:</strong><br>' .
'<pre>%s</pre>' .
'<small>Component: %s</small>' .
'</div>',
htmlspecialchars($e->getMessage()),
htmlspecialchars($element->getTagName())
);
$this->replaceWithHtml($element, $errorHtml);
} else {
// Production: Remove element
$parent = $element->getParent();
if ($parent) {
$parent->removeChild($element);
}
}
}
}

View File

@@ -1,97 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
final readonly class DomHeadService
{
public function appendToHead(DomWrapper $dom, string $html): void
{
$head = $dom->getElementsByTagName('head')->first();
if (! $head) {
// Fallback: head erstellen oder zu body hinzufügen
$head = $dom->document->createElement('head');
$dom->document->documentElement->insertBefore($head, $dom->document->body);
}
$fragment = $dom->createFragmentFromHtml($html);
$head->appendChild($fragment);
}
public function prependToHead(DomWrapper $dom, string $html): void
{
$head = $dom->getElementsByTagName('head')->first();
if (! $head) {
return;
}
$fragment = $dom->createFragmentFromHtml($html);
$head->insertBefore($fragment, $head->firstChild);
}
public function addStylesheet(DomWrapper $dom, string $href): void
{
error_log("DomHeadService::addStylesheet called with href: $href");
$link = $dom->document->createElement('link');
$link->setAttribute('rel', 'stylesheet');
$link->setAttribute('href', $href);
error_log("DomHeadService::addStylesheet - FIXED VERSION - Created link element with href: " . $link->getAttribute('href'));
// Use DomWrapper's method to find head
$headCollection = $dom->getElementsByTagName('head');
$head = $headCollection->first();
if ($head) {
error_log("DomHeadService::addStylesheet - head found via DomWrapper, nodeName: " . $head->nodeName);
// Debug: show head child count before
error_log("DomHeadService::addStylesheet - head child count before: " . $head->childNodes->length);
$head->appendChild($link);
// Debug: show head child count after
error_log("DomHeadService::addStylesheet - head child count after: " . $head->childNodes->length);
// Verify it was added
$links = $head->getElementsByTagName('link');
error_log("DomHeadService::addStylesheet - Total links in head after append: " . $links->length);
} else {
error_log("DomHeadService::addStylesheet - NO HEAD FOUND!");
// Debug: show what we have in document
error_log("DomHeadService::addStylesheet - Document structure: " . substr($dom->toHtml(), 0, 500));
}
}
public function addScript(DomWrapper $dom, string $src, array $attributes = []): void
{
$script = $dom->document->createElement('script');
$script->setAttribute('src', $src);
$script->setAttribute('type', 'module');
foreach ($attributes as $name => $value) {
$script->setAttribute($name, $value);
}
// Try multiple methods to get head element
$head = $dom->document->head ??
$dom->document->getElementsByTagName('head')->item(0);
if ($head) {
$head->appendChild($script);
}
}
public function addMeta(DomWrapper $dom, string $name, string $content): void
{
$meta = $dom->document->createElement('meta');
$meta->setAttribute('name', $name);
$meta->setAttribute('content', $content);
$head = $dom->getElementsByTagName('head')->first();
$head?->appendChild($meta);
}
}

View File

@@ -151,8 +151,29 @@ final readonly class DomWrapper
*/
public function replaceElementWithHtml(Element $element, string $html): void
{
$fragment = $this->createFragmentFromHtml($html);
$element->parentNode?->replaceChild($fragment, $element);
$parent = $element->parentNode;
if (!$parent) {
return;
}
// Create temporary wrapper to parse HTML
$tempWrapper = HTMLDocument::createFromString('<div>' . $html . '</div>');
$tempDiv = $tempWrapper->body->firstChild;
if (!$tempDiv) {
// Empty HTML, just remove element
$parent->removeChild($element);
return;
}
// Insert all children of temp div before the element
while ($tempDiv->firstChild) {
$importedNode = $this->document->importNode($tempDiv->firstChild, true);
$parent->insertBefore($importedNode, $element);
}
// Remove the original element
$parent->removeChild($element);
}
/**

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\TemplateErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
@@ -32,7 +32,7 @@ final class TemplateNotFoundException extends FrameworkException
return self::fromContext(
"Template \"$template\" nicht gefunden $pathInfo.",
$context,
ErrorCode::TPL_TEMPLATE_NOT_FOUND,
TemplateErrorCode::TEMPLATE_NOT_FOUND,
$previous
);
}
@@ -49,7 +49,7 @@ final class TemplateNotFoundException extends FrameworkException
return self::fromContext(
"Template \"$template\" und Fallback \"$fallbackTemplate\" nicht gefunden.",
$context,
ErrorCode::TPL_TEMPLATE_NOT_FOUND,
TemplateErrorCode::TEMPLATE_NOT_FOUND,
$previous
);
}
@@ -73,7 +73,7 @@ final class TemplateNotFoundException extends FrameworkException
parent::__construct(
message: "Template \"$template\" nicht gefunden ($file).",
context: $context,
errorCode: ErrorCode::TPL_TEMPLATE_NOT_FOUND,
errorCode: TemplateErrorCode::TEMPLATE_NOT_FOUND,
previous: $previous
);
}

View File

@@ -71,6 +71,7 @@ final readonly class FormBuilder
public function withClass(string $class): self
{
$newForm = $this->form->withClass($class);
return new self($newForm, $this->formIdGenerator, $this->elements);
}
@@ -187,4 +188,4 @@ final readonly class FormBuilder
{
return $this->build();
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Functions;
/**
* LazyComponentFunction
*
* Template function for lazy-loaded LiveComponents.
*
* Generates placeholder HTML for components that will be loaded
* when they enter the viewport using IntersectionObserver.
*
* Usage in templates:
* {{ lazy_component('notification-bell:user-123') }}
* {{ lazy_component('user-stats:456', ['priority' => 'high', 'threshold' => '0.1']) }}
* {{ lazy_component('feed:latest', ['placeholder' => 'Loading feed...']) }}
*
* Options:
* - priority: 'high' | 'normal' | 'low' (default: 'normal')
* - threshold: '0.0' to '1.0' (default: '0.1')
* - placeholder: Custom placeholder text (default: null)
* - rootMargin: IntersectionObserver root margin (default: null)
*
* Generated HTML:
* <div data-live-component-lazy="component-id"
* data-lazy-priority="normal"
* data-lazy-threshold="0.1"
* data-lazy-placeholder="Loading...">
* </div>
*/
final readonly class LazyComponentFunction implements TemplateFunction
{
public function __construct()
{
$this->functionName = 'lazy_component';
}
/**
* Generate lazy component placeholder HTML
*
* @param string $componentId Component ID (e.g., 'counter:demo')
* @param array $options Lazy loading options
* @return string Placeholder HTML
*/
public function __invoke(string $componentId, array $options = []): string
{
// Extract options with defaults
$priority = $options['priority'] ?? 'normal';
$threshold = $options['threshold'] ?? '0.1';
$placeholder = $options['placeholder'] ?? null;
$rootMargin = $options['rootMargin'] ?? null;
$class = $options['class'] ?? '';
// Validate priority
$validPriorities = ['high', 'normal', 'low'];
if (! in_array($priority, $validPriorities)) {
$priority = 'normal';
}
// Validate threshold
$thresholdFloat = (float) $threshold;
if ($thresholdFloat < 0.0 || $thresholdFloat > 1.0) {
$threshold = '0.1';
}
// Build attributes array
$attributes = [
'data-live-component-lazy' => htmlspecialchars($componentId, ENT_QUOTES | ENT_HTML5),
'data-lazy-priority' => htmlspecialchars($priority, ENT_QUOTES | ENT_HTML5),
'data-lazy-threshold' => htmlspecialchars($threshold, ENT_QUOTES | ENT_HTML5),
];
// Add optional attributes
if ($placeholder !== null) {
$attributes['data-lazy-placeholder'] = htmlspecialchars($placeholder, ENT_QUOTES | ENT_HTML5);
}
if ($rootMargin !== null) {
$attributes['data-lazy-root-margin'] = htmlspecialchars($rootMargin, ENT_QUOTES | ENT_HTML5);
}
if (! empty($class)) {
$attributes['class'] = htmlspecialchars($class, 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>', $attributesHtml);
}
public string $functionName;
}

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Lexer;
final class HtmlLexer
{
private const COMMENT_START = '!--';
private const COMMENT_END = '-->';
private const DOCTYPE_UPPER = '!DOCTYPE';
private const DOCTYPE_LOWER = '!doctype';
private const CDATA_START = '![CDATA[';
private const CDATA_END = ']]>';
private const TAG_NAME_PATTERN = '/<([a-z][a-z0-9-]*)/i';
/** @var array<string> Raw content tags (script, style, etc.) */
private const RAW_TEXT_TAGS = ['script', 'style'];
private string $html;
private int $position;
private int $length;
private ?string $currentTagName = null;
public function __construct(string $html)
{
$this->html = $html;
$this->position = 0;
$this->length = mb_strlen($html, '8bit');
}
/**
* Tokenize HTML and return array of tokens
*
* @return array<Token>
*/
public function tokenize(): array
{
$tokens = [];
$this->position = 0;
while ($this->position < $this->length) {
// Check for tag start
if ($this->current() === '<') {
$token = $this->consumeTag();
$tokens[] = $token;
} else {
// Consume content until next tag
$content = $this->consumeUntil('<');
if ($content !== '') {
$tokens[] = new Token($content, TokenType::CONTENT);
}
}
}
return $tokens;
}
/**
* Normalize self-closing x-components to explicit closing tags
*/
public function normalizeXComponents(): string
{
$tokens = $this->tokenize();
$parts = [];
foreach ($tokens as $token) {
if ($token->type === TokenType::SELF_CLOSING_TAG && $this->isXComponent($token->content)) {
// Convert <x-foo /> to <x-foo></x-foo>
$tagContent = trim($token->content);
$tagContent = rtrim($tagContent, '/>');
$tagContent = trim($tagContent);
// Extract tag name (e.g., "x-counter" from "<x-counter" or "<x-counter attr='val'")
preg_match(self::TAG_NAME_PATTERN, $tagContent, $matches);
$tagName = $matches[1] ?? '';
if ($tagName !== '') {
$parts[] = $tagContent . '></' . $tagName . '>';
} else {
// Fallback: keep original if we can't parse
$parts[] = $token->content;
}
} else {
$parts[] = $token->content;
}
}
return implode('', $parts);
}
private function consumeTag(): Token
{
$start = $this->position;
// Consume '<'
$this->advance();
// Check for special cases
if ($this->peek(mb_strlen(self::COMMENT_START, '8bit')) === self::COMMENT_START) {
return $this->consumeComment($start);
}
if ($this->peek(mb_strlen(self::CDATA_START, '8bit')) === self::CDATA_START) {
return $this->consumeCData($start);
}
if ($this->peek(mb_strlen(self::DOCTYPE_UPPER, '8bit')) === self::DOCTYPE_UPPER
|| $this->peek(mb_strlen(self::DOCTYPE_LOWER, '8bit')) === self::DOCTYPE_LOWER) {
return $this->consumeDoctype($start);
}
// Check for closing tag
if ($this->current() === '/') {
return $this->consumeClosingTag($start);
}
// Consume opening tag
return $this->consumeOpeningTag($start);
}
private function consumeCData(int $start): Token
{
// Consume until ']]>'
$cdataEndLength = mb_strlen(self::CDATA_END, '8bit');
while ($this->position < $this->length) {
if ($this->peek($cdataEndLength) === self::CDATA_END) {
for ($i = 0; $i < $cdataEndLength; $i++) {
$this->advance();
}
break;
}
$this->advance();
}
return new Token(substr($this->html, $start, $this->position - $start), TokenType::CDATA);
}
private function consumeOpeningTag(int $start): Token
{
$tagNameStart = $this->position;
// Consume tag name
while ($this->position < $this->length
&& !ctype_space($this->current())
&& $this->current() !== '>'
&& $this->current() !== '/') {
$this->advance();
}
// Extract tag name for raw text handling
$tagName = strtolower(substr($this->html, $tagNameStart, $this->position - $tagNameStart));
// Consume attributes with proper quote handling
$inQuote = false;
$quoteChar = '';
while ($this->position < $this->length && $this->current() !== '>') {
$char = $this->current();
// Handle quotes in attributes
if (($char === '"' || $char === "'") && !$inQuote) {
$inQuote = true;
$quoteChar = $char;
} elseif ($inQuote && $char === $quoteChar) {
$inQuote = false;
$quoteChar = '';
}
$this->advance();
// Don't break on '>' inside quotes
if ($this->current() === '>' && $inQuote) {
continue;
}
}
// Check if this is a self-closing tag
if ($this->position > 0 && $this->html[$this->position - 1] === '/') {
// Self-closing tag detected: <tag />
$this->advance(); // consume '>'
return new Token(substr($this->html, $start, $this->position - $start), TokenType::SELF_CLOSING_TAG);
}
// Regular opening tag
if ($this->current() === '>') {
$this->advance(); // consume '>'
}
// Track if we entered a raw text tag
if (in_array($tagName, self::RAW_TEXT_TAGS, true)) {
$this->currentTagName = $tagName;
}
return new Token(substr($this->html, $start, $this->position - $start), TokenType::OPEN_TAG_START);
}
private function consumeClosingTag(int $start): Token
{
// Consume '/'
$this->advance();
$tagNameStart = $this->position;
// Consume tag name
while ($this->position < $this->length
&& !ctype_space($this->current())
&& $this->current() !== '>') {
$this->advance();
}
$tagName = strtolower(substr($this->html, $tagNameStart, $this->position - $tagNameStart));
// Consume until '>'
while ($this->position < $this->length && $this->current() !== '>') {
$this->advance();
}
if ($this->current() === '>') {
$this->advance();
}
// Reset current tag if we're closing a raw text tag
if ($this->currentTagName === $tagName) {
$this->currentTagName = null;
}
return new Token(substr($this->html, $start, $this->position - $start), TokenType::CLOSING_TAG);
}
private function consumeComment(int $start): Token
{
// Consume until '-->'
$commentEndLength = mb_strlen(self::COMMENT_END, '8bit');
while ($this->position < $this->length) {
if ($this->peek($commentEndLength) === self::COMMENT_END) {
// Consume all three characters: -->
for ($i = 0; $i < $commentEndLength; $i++) {
$this->advance();
}
break;
}
$this->advance();
}
return new Token(substr($this->html, $start, $this->position - $start), TokenType::COMMENT);
}
private function consumeDoctype(int $start): Token
{
// Consume until '>'
while ($this->position < $this->length && $this->current() !== '>') {
$this->advance();
}
if ($this->current() === '>') {
$this->advance();
}
return new Token(substr($this->html, $start, $this->position - $start), TokenType::DOCTYPE);
}
private function consumeUntil(string $char): string
{
$start = $this->position;
// If we're inside a raw text tag (script/style), consume until closing tag
if ($this->currentTagName !== null) {
$closingTag = '</' . $this->currentTagName;
while ($this->position < $this->length) {
if ($this->peek(mb_strlen($closingTag, '8bit')) === $closingTag) {
break;
}
$this->advance();
}
} else {
while ($this->position < $this->length && $this->current() !== $char) {
$this->advance();
}
}
return substr($this->html, $start, $this->position - $start);
}
private function isXComponent(string $tagContent): bool
{
// Check if tag starts with <x- (case-insensitive)
return preg_match('/^<x-[a-z0-9][a-z0-9-]*/i', $tagContent) === 1;
}
private function current(): string
{
if ($this->position >= $this->length) {
return '';
}
return $this->html[$this->position];
}
private function peek(int $length): string
{
if ($this->position + $length > $this->length) {
return '';
}
return substr($this->html, $this->position, $length);
}
private function advance(): void
{
$this->position++;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Lexer;
final readonly class Token
{
public function __construct(
public string $content,
public TokenType $type
) {}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Lexer;
enum TokenType
{
case OPEN_TAG_START;
case OPEN_TAG_END;
case SELF_CLOSING_TAG;
case CLOSING_TAG;
case CONTENT;
case ATTRIBUTE_NAME;
case ATTRIBUTE_VALUE;
case COMMENT;
case DOCTYPE;
case CDATA;
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Lexer;
final readonly class XComponentNormalizer
{
private const TAG_NAME_PATTERN = '/<([a-z][a-z0-9-]*)/i';
private const X_COMPONENT_PATTERN = '/^<x-[a-z0-9][a-z0-9-]*/i';
public function __construct(
private HtmlLexer $lexer
) {}
/**
* Normalize self-closing x-components to explicit closing tags
* Example: <x-foo /> becomes <x-foo></x-foo>
*/
public function normalize(string $html): string
{
$lexer = new HtmlLexer($html);
$tokens = $lexer->tokenize();
$parts = [];
foreach ($tokens as $token) {
if ($token->type === TokenType::SELF_CLOSING_TAG && $this->isXComponent($token->content)) {
$parts[] = $this->convertToExplicitClosing($token->content);
} else {
$parts[] = $token->content;
}
}
return implode('', $parts);
}
private function convertToExplicitClosing(string $selfClosingTag): string
{
$tagContent = trim($selfClosingTag);
$tagContent = rtrim($tagContent, '/>');
$tagContent = trim($tagContent);
preg_match(self::TAG_NAME_PATTERN, $tagContent, $matches);
$tagName = $matches[1] ?? '';
if ($tagName === '') {
// Fallback: keep original if we can't parse
return $selfClosingTag;
}
return $tagContent . '></' . $tagName . '>';
}
private function isXComponent(string $tagContent): bool
{
return preg_match(self::X_COMPONENT_PATTERN, $tagContent) === 1;
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\Meta\MetaData;
/**
* Renders LiveComponent templates
*
* Separates rendering concerns from LiveComponent business logic.
* LiveComponents should only handle state and actions, not template rendering.
*/
final readonly class LiveComponentRenderer
{
public function __construct(
private TemplateRenderer $templateRenderer,
private SessionInterface $session
) {
}
/**
* Render a LiveComponent template with data
*
* @param string $templatePath Template path (without extension)
* @param array $data Template data
* @param string $componentId Full component ID (e.g., "counter:demo")
* @return string Rendered HTML
*/
public function render(string $templatePath, array $data, string $componentId): string
{
// Merge component ID into data for templates
$templateData = array_merge($data, [
'componentId' => $componentId,
]);
// Create component render context
// Use COMPONENT mode to enable template logic (ForProcessor, IfProcessor, etc.)
// but skip layout/meta/asset processors
$context = new RenderContext(
template: $templatePath,
metaData: new MetaData(''),
data: $templateData,
processingMode: ProcessingMode::COMPONENT
);
return $this->templateRenderer->renderPartial($context);
}
/**
* Render component wrapper HTML with state, CSRF protection, SSE support, and polling
*
* Generates a component-specific CSRF token for secure action execution.
* Each component instance gets its own token for isolation.
*
* If the component supports SSE (has getSseChannel() method), the SSE channel
* will be rendered as data-sse-channel attribute for automatic real-time updates.
*
* If the component implements Pollable interface, the poll interval (in milliseconds)
* will be rendered as data-poll-interval attribute for automatic polling.
*
* @param string $componentId Full component ID (e.g., "counter:demo")
* @param string $componentHtml Rendered component HTML
* @param array $state Component state data
* @param string|null $sseChannel Optional SSE channel for real-time updates
* @param int|null $pollInterval Optional poll interval in milliseconds
* @return string Complete component HTML with wrapper
*/
public function renderWithWrapper(
string $componentId,
string $componentHtml,
array $state,
?string $sseChannel = null,
?int $pollInterval = null
): string {
// Extract component name from ID (format: "counter:demo")
$componentName = explode(':', $componentId)[0] ?? 'unknown';
// Generate component-specific CSRF token
// Use component ID as form ID for per-component isolation
$formId = 'livecomponent:' . $componentId;
$csrfToken = $this->session->csrf->generateToken($formId);
$stateJson = json_encode([
'id' => $componentId,
'component' => $componentName,
'data' => $state,
'version' => 1,
]);
// Build attributes
$attributes = [
'data-live-component' => $componentId,
'data-state' => $stateJson,
'data-csrf-token' => $csrfToken->toString(),
];
// Add SSE channel if provided
if ($sseChannel !== null) {
$attributes['data-sse-channel'] = $sseChannel;
}
// Add poll interval if provided
if ($pollInterval !== null) {
$attributes['data-poll-interval'] = (string) $pollInterval;
}
// Build attribute string
// IMPORTANT: data-state is already JSON and will be parsed by HTMLDocument
// which handles escaping correctly. We DON'T htmlspecialchars the JSON
// because it gets parsed and re-serialized by DOM, which handles escaping.
$attributeString = implode(' ', array_map(
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') {
return sprintf('%s=\'%s\'', $key, $value);
}
// For other attributes, use standard escaping
return sprintf('%s="%s"', $key, htmlspecialchars($value, ENT_QUOTES, 'UTF-8'));
},
array_keys($attributes),
$attributes
));
return sprintf(
'<div %s>%s</div>',
$attributeString,
$componentHtml
);
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Framework\View\Loading\Resolvers;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Filesystem\FileSystemService;
final readonly class DiscoveryResolver implements TemplateResolverStrategy

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processing;
use App\Framework\Template\Processing\AstTransformer;
use App\Framework\View\Dom\DocumentNode;
use App\Framework\View\Dom\Parser\HtmlParser;
use App\Framework\View\RenderContext;
/**
* AstProcessingPipeline - Process templates through AST transformers
*
* Modern replacement for DomProcessingPipeline using AST instead of DOM.
*
* Pipeline flow:
* 1. Parse HTML to AST via HtmlParser
* 2. Apply each AstTransformer in sequence
* 3. Return transformed AST (caller renders to HTML)
*/
final readonly class AstProcessingPipeline
{
/**
* @param array<AstTransformer> $transformers Transformer instances
* @param HtmlParser $parser HTML to AST parser
*/
public function __construct(
private array $transformers,
private HtmlParser $parser
) {
}
/**
* Process HTML through AST transformers
*
* @param RenderContext $context Rendering context
* @param string $html Input HTML
* @return DocumentNode Transformed AST
*/
public function process(RenderContext $context, string $html): DocumentNode
{
// Parse HTML to AST
$document = $this->parser->parse($html);
// Apply each transformer in sequence
foreach ($this->transformers as $transformer) {
try {
$document = $transformer->transform($document, $context);
} catch (\Throwable $e) {
// Log error and rethrow with context
error_log(sprintf(
'AstProcessingPipeline: Error in %s: %s',
get_class($transformer),
$e->getMessage()
));
throw $e;
}
}
return $document;
}
/**
* Get list of transformers
*
* @return array<AstTransformer>
*/
public function getTransformers(): array
{
return $this->transformers;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
/**
* Defines the processing mode for template rendering
*/
enum ProcessingMode: string
{
/**
* Full processing - all DOM and string processors
* Used for: Regular pages with layouts, meta tags, etc.
*/
case FULL = 'full';
/**
* Component processing - template logic processors only
* Used for: LiveComponents, partials that need ForProcessor, IfProcessor, etc.
* Skips: Layout, Meta, Asset injection processors
*/
case COMPONENT = 'component';
/**
* Minimal processing - string processors only, no DOM processing
* Used for: Simple string replacements without template logic
*/
case MINIMAL = 'minimal';
}

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
/**
* Template Processor Chain Optimizer
*
* Optimiert die Reihenfolge der Template Processors basierend auf:
* - Häufigkeit der Verwendung (häufig verwendete Processors zuerst)
* - Durchschnittliche Execution Time (schnelle Processors zuerst)
* - Abhängigkeiten zwischen Processors
*
* Performance Impact:
* - ~20-30% schnellere Template-Verarbeitung durch optimierte Reihenfolge
* - Früher Exit bei No-Op Processors
* - Reduzierte Template-Parsing-Overhead
*/
final readonly class ProcessorChainOptimizer
{
private const CACHE_KEY_PREFIX = 'template:processor-order:';
private const CACHE_TTL_HOURS = 24;
public function __construct(
private Cache $cache
) {
}
/**
* Optimiert die Processor-Reihenfolge basierend auf Template-Inhalt
*
* Strategie:
* 1. Analyse welche Processors für dieses Template relevant sind
* 2. Sortierung nach Häufigkeit und Performance
* 3. Caching der optimierten Reihenfolge
*
* @param array<TemplateProcessor> $processors
* @param string $templateContent
* @return array<TemplateProcessor>
*/
public function optimize(array $processors, string $templateContent): array
{
$cacheKey = $this->getCacheKey($templateContent);
// Try cache first
$cachedOrder = $this->cache->get($cacheKey);
if ($cachedOrder->isHit) {
return $this->reorderProcessors($processors, $cachedOrder->value);
}
// Analyze template content
$analysis = $this->analyzeTemplate($templateContent);
// Determine processor order
$order = $this->determineOptimalOrder($processors, $analysis);
// Cache the order
$this->cache->set(
CacheItem::forSet(
key: $cacheKey,
value: $order,
ttl: Duration::fromHours(self::CACHE_TTL_HOURS)
)
);
return $this->reorderProcessors($processors, $order);
}
/**
* Analysiert Template-Inhalt um Processor-Relevanz zu bestimmen
*
* @return array{
* has_placeholders: bool,
* has_if_conditions: bool,
* has_for_loops: bool,
* has_components: bool,
* has_slots: bool,
* has_layout: bool,
* placeholder_count: int,
* if_count: int,
* for_count: int,
* component_count: int
* }
*/
private function analyzeTemplate(string $templateContent): array
{
return [
// Placeholder detection
'has_placeholders' => str_contains($templateContent, '{') && str_contains($templateContent, '}'),
'placeholder_count' => substr_count($templateContent, '{'),
// Conditional detection
'has_if_conditions' => str_contains($templateContent, '<if ') || str_contains($templateContent, '<if>'),
'if_count' => substr_count($templateContent, '<if '),
// Loop detection
'has_for_loops' => str_contains($templateContent, '<for ') || str_contains($templateContent, '<for>'),
'for_count' => substr_count($templateContent, '<for '),
// Component detection
'has_components' => str_contains($templateContent, '<include '),
'component_count' => substr_count($templateContent, '<include '),
// Slot detection
'has_slots' => str_contains($templateContent, '<slot ') || str_contains($templateContent, '</slot>'),
// Layout detection
'has_layout' => str_contains($templateContent, '<layout ') || str_contains($templateContent, '<content>'),
];
}
/**
* Bestimmt optimale Processor-Reihenfolge basierend auf Template-Analyse
*
* Strategie:
* 1. Irrelevante Processors überspringen (früher Exit)
* 2. Häufig verwendete Processors zuerst (hoher Impact)
* 3. Schnelle Processors vor langsamen (reduzierte Latenz)
*
* @param array<TemplateProcessor> $processors
* @param array $analysis
* @return array<string> Processor class names in optimal order
*/
private function determineOptimalOrder(array $processors, array $analysis): array
{
$order = [];
$scores = [];
foreach ($processors as $processor) {
$className = $processor::class;
$score = $this->calculateProcessorScore($className, $analysis);
// Skip processors with score 0 (irrelevant for this template)
if ($score > 0) {
$scores[$className] = $score;
}
}
// Sort by score descending (highest score first)
arsort($scores);
return array_keys($scores);
}
/**
* Berechnet Processor-Score basierend auf Template-Relevanz
*
* Höherer Score = Früher in der Kette
*/
private function calculateProcessorScore(string $processorClass, array $analysis): int
{
$score = 0;
// PlaceholderReplacer: Fast & häufig verwendet
if (str_contains($processorClass, 'PlaceholderReplacer')) {
$score = $analysis['has_placeholders'] ? 100 + $analysis['placeholder_count'] : 0;
}
// IfTransformer: Conditional rendering (häufig)
if (str_contains($processorClass, 'IfTransformer') || str_contains($processorClass, 'IfProcessor')) {
$score = $analysis['has_if_conditions'] ? 80 + ($analysis['if_count'] * 5) : 0;
}
// ForProcessor: Loops (performance-kritisch)
if (str_contains($processorClass, 'ForProcessor')) {
$score = $analysis['has_for_loops'] ? 70 + ($analysis['for_count'] * 10) : 0;
}
// ComponentProcessor: Component inclusion (teuer)
if (str_contains($processorClass, 'ComponentProcessor')) {
$score = $analysis['has_components'] ? 60 + ($analysis['component_count'] * 8) : 0;
}
// SlotProcessor: Slot handling
if (str_contains($processorClass, 'SlotProcessor')) {
$score = $analysis['has_slots'] ? 50 : 0;
}
// LayoutTagTransformer: Layout system (wenn verwendet)
if (str_contains($processorClass, 'LayoutTagTransformer') || str_contains($processorClass, 'LayoutTagProcessor')) {
$score = $analysis['has_layout'] ? 40 : 0;
}
// MetaManipulatorTransformer: Meta tags (weniger häufig)
if (str_contains($processorClass, 'MetaManipulatorTransformer') || str_contains($processorClass, 'MetaManipulator')) {
$score = 30;
}
// AssetInjectorTransformer: Asset injection (fast)
if (str_contains($processorClass, 'AssetInjectorTransformer') || str_contains($processorClass, 'AssetInjector')) {
$score = 20;
}
// CsrfTokenProcessor: Security (always needed but fast)
if (str_contains($processorClass, 'CsrfTokenProcessor')) {
$score = 10;
}
return $score;
}
/**
* Ordnet Processors basierend auf optimaler Reihenfolge neu
*
* @param array<TemplateProcessor> $processors
* @param array<string> $order Class names in order
* @return array<TemplateProcessor>
*/
private function reorderProcessors(array $processors, array $order): array
{
$ordered = [];
// Add processors in specified order
foreach ($order as $className) {
foreach ($processors as $processor) {
if ($processor::class === $className) {
$ordered[] = $processor;
break;
}
}
}
// Add any remaining processors that weren't in the order
// (shouldn't happen in normal operation)
foreach ($processors as $processor) {
if (! in_array($processor, $ordered, true)) {
$ordered[] = $processor;
}
}
return $ordered;
}
/**
* Generiert Cache-Key basierend auf Template-Struktur
*/
private function getCacheKey(string $templateContent): CacheKey
{
// Hash nur der Template-Struktur (nicht Inhalt) für besseres Caching
$structureHash = md5(
preg_replace('/\{[^}]+\}/', '{VAR}', $templateContent) ?? ''
);
return CacheKey::fromString(self::CACHE_KEY_PREFIX . $structureHash);
}
/**
* Invalidiert alle gecachten Processor-Orders
*/
public function invalidateAll(): void
{
// Note: Würde Wildcard-Deletion erfordern
// Implementation abhängig vom Cache-Driver
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Framework\View;
/**
* Processor Instruction Value Object
*
* Repräsentiert eine einzelne Processor-Anweisung im Template
*/
final readonly class ProcessorInstruction
{
public function __construct(
public string $processorType,
public string $pattern,
public array $metadata
) {}
public function toArray(): array
{
return [
'processor_type' => $this->processorType,
'pattern' => $this->pattern,
'metadata' => $this->metadata,
];
}
public static function fromArray(array $data): self
{
return new self(
processorType: $data['processor_type'],
pattern : $data['pattern'],
metadata : $data['metadata']
);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Framework\View;
/**
* Processor Metrics Value Object
*/
final readonly class ProcessorMetrics
{
public function __construct(
public string $processorClass,
public int $invocations = 0,
public float $totalExecutionTimeMs = 0.0,
public float $averageExecutionTimeMs = 0.0,
public float $minExecutionTimeMs = PHP_FLOAT_MAX,
public float $maxExecutionTimeMs = 0.0,
public int $totalMemoryUsed = 0,
public int $averageMemoryUsed = 0
) {}
/**
* Fügt neue Execution-Daten hinzu
*/
public function withExecution(float $executionTimeMs, int $memoryUsed): self
{
$newInvocations = $this->invocations + 1;
$newTotalTime = $this->totalExecutionTimeMs + $executionTimeMs;
$newTotalMemory = $this->totalMemoryUsed + $memoryUsed;
return new self(
processorClass : $this->processorClass,
invocations : $newInvocations,
totalExecutionTimeMs : $newTotalTime,
averageExecutionTimeMs: $newTotalTime / $newInvocations,
minExecutionTimeMs : min($this->minExecutionTimeMs, $executionTimeMs),
maxExecutionTimeMs : max($this->maxExecutionTimeMs, $executionTimeMs),
totalMemoryUsed : $newTotalMemory,
averageMemoryUsed : (int)($newTotalMemory / $newInvocations)
);
}
/**
* Berechnet Performance-Grade (A-F)
*/
public function getPerformanceGrade(): string
{
return match (true) {
$this->averageExecutionTimeMs < 1.0 => 'A', // < 1ms
$this->averageExecutionTimeMs < 5.0 => 'B', // 1-5ms
$this->averageExecutionTimeMs < 10.0 => 'C', // 5-10ms
$this->averageExecutionTimeMs < 20.0 => 'D', // 10-20ms
$this->averageExecutionTimeMs < 50.0 => 'E', // 20-50ms
default => 'F' // > 50ms
};
}
/**
* Konvertiert zu Array
*/
public function toArray(): array
{
return [
'processor_class' => $this->processorClass,
'invocations' => $this->invocations,
'total_execution_time_ms' => round($this->totalExecutionTimeMs, 3),
'average_execution_time_ms' => round($this->averageExecutionTimeMs, 3),
'min_execution_time_ms' => round($this->minExecutionTimeMs, 3),
'max_execution_time_ms' => round($this->maxExecutionTimeMs, 3),
'total_memory_used' => $this->totalMemoryUsed,
'average_memory_used' => $this->averageMemoryUsed,
'performance_grade' => $this->getPerformanceGrade(),
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Framework\View;
/**
* Processor Performance Report Value Object
*/
final readonly class ProcessorPerformanceReport
{
/**
* @param array<ProcessorMetrics> $processors
*/
public function __construct(
public array $processors,
public float $totalExecutionTimeMs,
public int $totalInvocations,
public float $averageExecutionTimeMs
) {}
/**
* Formatiert Report als lesbaren Text
*/
public function format(): string
{
$output = "Template Processor Performance Report\n";
$output .= "======================================\n\n";
$output .= sprintf(
"Total Execution Time: %.2fms\n",
$this->totalExecutionTimeMs
);
$output .= sprintf(
"Total Invocations: %d\n",
$this->totalInvocations
);
$output .= sprintf(
"Average Execution Time: %.2fms\n\n",
$this->averageExecutionTimeMs
);
$output .= "Processor Details:\n";
$output .= str_repeat("-", 80) . "\n";
foreach ($this->processors as $metrics) {
$processorName = substr($metrics->processorClass, strrpos($metrics->processorClass, '\\') + 1);
$output .= sprintf(
"%-40s | %5d calls | Avg: %6.2fms | Grade: %s\n",
$processorName,
$metrics->invocations,
$metrics->averageExecutionTimeMs,
$metrics->getPerformanceGrade()
);
}
$output .= str_repeat("-", 80) . "\n";
return $output;
}
/**
* Konvertiert zu Array
*/
public function toArray(): array
{
return [
'summary' => [
'total_execution_time_ms' => round($this->totalExecutionTimeMs, 3),
'total_invocations' => $this->totalInvocations,
'average_execution_time_ms' => round($this->averageExecutionTimeMs, 3),
],
'processors' => array_map(
fn(ProcessorMetrics $m) => $m->toArray(),
$this->processors
),
];
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
/**
* Processor Performance Tracker
*
* Misst und analysiert Performance einzelner Template Processors
* für Optimierungs-Entscheidungen.
*
* Metriken:
* - Execution Time pro Processor
* - Anzahl Aufrufe
* - Durchschnittliche Latenz
* - Template-spezifische Performance
*
* Performance Impact:
* - Minimal (< 0.1ms Overhead pro Measurement)
* - Nur in Development/Profiling Mode aktiv
* - Sammelt Daten für Optimierungs-Entscheidungen
*/
final class ProcessorPerformanceTracker
{
/** @var array<string, ProcessorMetrics> */
private array $metrics = [];
private bool $enabled = false;
/**
* Aktiviert Performance-Tracking
*/
public function enable(): void
{
$this->enabled = true;
}
/**
* Deaktiviert Performance-Tracking
*/
public function disable(): void
{
$this->enabled = false;
}
/**
* Misst Processor-Execution
*
* @param string $processorClass
* @param callable $execution
* @return string Verarbeitetes Template
*/
public function measure(string $processorClass, callable $execution): string
{
if (! $this->enabled) {
return $execution();
}
$startTime = microtime(true);
$startMemory = memory_get_usage();
$result = $execution();
$executionTimeMs = (microtime(true) - $startTime) * 1000;
$memoryUsed = memory_get_usage() - $startMemory;
$this->recordMetric($processorClass, $executionTimeMs, $memoryUsed);
return $result;
}
/**
* Zeichnet Metrik auf
*/
private function recordMetric(string $processorClass, float $executionTimeMs, int $memoryUsed): void
{
if (! isset($this->metrics[$processorClass])) {
$this->metrics[$processorClass] = new ProcessorMetrics($processorClass);
}
$this->metrics[$processorClass] = $this->metrics[$processorClass]->withExecution(
$executionTimeMs,
$memoryUsed
);
}
/**
* Ruft Metriken für Processor ab
*/
public function getMetrics(string $processorClass): ?ProcessorMetrics
{
return $this->metrics[$processorClass] ?? null;
}
/**
* Ruft alle Metriken ab, sortiert nach Execution Time
*
* @return array<ProcessorMetrics>
*/
public function getAllMetrics(): array
{
$metrics = array_values($this->metrics);
usort(
$metrics,
fn (ProcessorMetrics $a, ProcessorMetrics $b) =>
$b->averageExecutionTimeMs <=> $a->averageExecutionTimeMs
);
return $metrics;
}
/**
* Generiert Performance-Report
*/
public function generateReport(): ProcessorPerformanceReport
{
$totalExecutionTime = array_sum(array_map(
fn (ProcessorMetrics $m) => $m->totalExecutionTimeMs,
$this->metrics
));
$totalInvocations = array_sum(array_map(
fn (ProcessorMetrics $m) => $m->invocations,
$this->metrics
));
return new ProcessorPerformanceReport(
processors: $this->getAllMetrics(),
totalExecutionTimeMs: $totalExecutionTime,
totalInvocations: $totalInvocations,
averageExecutionTimeMs: $totalInvocations > 0 ? $totalExecutionTime / $totalInvocations : 0.0
);
}
/**
* Identifiziert langsame Processors (> 10ms average)
*
* @return array<ProcessorMetrics>
*/
public function findSlowProcessors(float $thresholdMs = 10.0): array
{
return array_filter(
$this->metrics,
fn (ProcessorMetrics $m) => $m->averageExecutionTimeMs > $thresholdMs
);
}
/**
* Resettet alle Metriken
*/
public function reset(): void
{
$this->metrics = [];
}
}

View File

@@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\Core\PathProvider;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomHeadService;
use App\Framework\View\DomWrapper;
use App\Framework\View\Exceptions\TemplateProcessorException;
use App\Framework\View\RenderContext;
final class AssetInjector implements DomProcessor
{
private array $manifest;
public function __construct(
PathProvider $pathProvider,
private DomHeadService $headService,
string $manifestPath = '',
) {
$manifestPath = $pathProvider->resolvePath('/public/.vite/manifest.json');
;
#$manifestPath = dirname(__DIR__, 3) . '../public/.vite/manifest.json';
if (! is_file($manifestPath)) {
throw TemplateProcessorException::manifestNotFound($manifestPath);
}
$json = file_get_contents($manifestPath);
$this->manifest = json_decode($json, true) ?? [];
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
// Skip if this is a partial render (e.g. component)
if ($context->isPartial ?? false) {
return $dom;
}
// JS-Key, wie im Manifest unter "resources/js/main.js"
$jsKey = 'resources/js/main.js';
// Debug: Log what we have in manifest
error_log("AssetInjector: Processing with manifest: " . json_encode($this->manifest));
// Check if we have the JS key in manifest
if (! isset($this->manifest[$jsKey])) {
error_log("AssetInjector: Key '$jsKey' not found in manifest!");
return $dom;
}
// Debug: Log the CSS array
if (isset($this->manifest[$jsKey]['css'])) {
error_log("AssetInjector: CSS array found: " . json_encode($this->manifest[$jsKey]['css']));
} else {
error_log("AssetInjector: No CSS array in manifest entry!");
}
// Use DomHeadService instead of direct manipulation
if (! empty($this->manifest[$jsKey]['css']) && is_array($this->manifest[$jsKey]['css'])) {
foreach ($this->manifest[$jsKey]['css'] as $cssFile) {
error_log("AssetInjector: Adding CSS file: " . $cssFile);
$this->headService->addStylesheet($dom, '/' . ltrim($cssFile, '/'));
}
}
// --- JS Main Script ---
if (isset($this->manifest[$jsKey]['file']) && str_ends_with($this->manifest[$jsKey]['file'], '.js')) {
error_log("AssetInjector: Adding JS file: " . $this->manifest[$jsKey]['file']);
$this->headService->addScript($dom, '/' . ltrim($this->manifest[$jsKey]['file'], '/'));
}
return $dom;
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
use Dom\Node;
final readonly class CommentStripProcessor implements DomProcessor
{
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
$this->removeComments($dom->document);
return $dom;
}
private function removeComments(Node $node): void
{
// Wir gehen rekursiv durch alle Childnodes
for ($i = $node->childNodes->length - 1; $i >= 0; $i--) {
$child = $node->childNodes->item($i);
if ($child->nodeType === XML_COMMENT_NODE) {
$node->removeChild($child);
} else {
// Rekursion in die nächste Ebene
$this->removeComments($child);
}
}
}
}

View File

@@ -33,7 +33,7 @@ final readonly class ComponentProcessor implements DomProcessor
/** @var HTMLElement $component */
$name = $component->getAttribute('name');
if (!$name) {
if (! $name) {
return;
}

View File

@@ -15,15 +15,53 @@ final class ForProcessor implements DomProcessor
{
public function __construct(
private Container $container,
) {}
) {
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
$forNodes = $dom->document->querySelectorAll('for[var][in]');
// Debug: show a snippet of HTML around select elements
$selects = $dom->document->querySelectorAll('select');
foreach ($selects as $idx => $select) {
$html = $dom->document->saveHTML($select);
$snippet = substr($html, 0, 200);
}
// Debug: show all <for> elements in document
$allForElements = $dom->document->querySelectorAll('for');
foreach ($allForElements as $idx => $forEl) {
$attrs = [];
foreach ($forEl->attributes as $attr) {
$attrs[] = $attr->name . '="' . $attr->value . '"';
}
}
// Support both syntaxes: var/in (old) and items/as (new)
$forNodesOld = $dom->document->querySelectorAll('for[var][in]');
$forNodesNew = $dom->document->querySelectorAll('for[items][as]');
// Merge both nodesets
$forNodes = [];
foreach ($forNodesOld as $node) {
$forNodes[] = $node;
}
foreach ($forNodesNew as $node) {
$forNodes[] = $node;
}
foreach ($forNodes as $node) {
$var = $node->getAttribute('var');
$in = $node->getAttribute('in');
// Detect which syntax is being used
if ($node->hasAttribute('items') && $node->hasAttribute('as')) {
// New syntax: <for items="arrayName" as="itemVar">
$in = $node->getAttribute('items');
$var = $node->getAttribute('as');
} else {
// Old syntax: <for var="itemVar" in="arrayName">
$var = $node->getAttribute('var');
$in = $node->getAttribute('in');
}
$output = '';
// Resolve items from context data or model
@@ -81,7 +119,7 @@ final class ForProcessor implements DomProcessor
}
// Replace for node with processed output
if (!empty($output)) {
if (! empty($output)) {
try {
$replacement = $dom->document->createDocumentFragment();
@$replacement->appendXML($output);
@@ -218,6 +256,7 @@ final class ForProcessor implements DomProcessor
// Find row with placeholders (template row)
if (str_contains($rowHtml, '{{')) {
$content = $rowHtml;
break 2;
}
}

View File

@@ -52,40 +52,71 @@ final readonly class ForStringProcessor implements StringProcessor
*/
private function processForLoops(string $content, RenderContext $context): string
{
// Find the innermost <for> loop by looking for <for> tags that don't contain other <for> tags
$pattern = '/<for\s+var="([^"]+)"\s+in="([^"]+)"[^>]*>((?:(?!<for\s).)*?)<\/for>/s';
// Support both syntaxes:
// Old: <for var="..." in="...">
// New: <for items="..." as="...">
return preg_replace_callback(
$pattern,
// Try new syntax first
$patternNew = '/<for\s+items="([^"]+)"\s+as="([^"]+)"[^>]*>((?:(?!<for\s).)*?)<\/for>/s';
$content = preg_replace_callback(
$patternNew,
function ($matches) use ($context) {
$varName = $matches[1];
$dataKey = $matches[2];
$dataKey = $matches[1]; // items="..."
$varName = $matches[2]; // as="..."
$template = $matches[3];
error_log("🔧 ForStringProcessor: Processing loop - var='$varName', in='$dataKey'");
error_log("🔧 ForStringProcessor: Processing NEW syntax loop - items='$dataKey', as='$varName'");
error_log("🔧 ForStringProcessor: Template content: " . substr($template, 0, 200));
// Resolve the data array/collection
$data = $this->resolveValue($context->data, $dataKey);
if (! is_array($data) && ! is_iterable($data)) {
error_log("🔧 ForStringProcessor: Data for '$dataKey' is not iterable: " . gettype($data));
return $matches[0]; // Return original if not iterable
}
$result = '';
foreach ($data as $item) {
$processedTemplate = $this->replaceLoopVariables($template, $varName, $item);
$result .= $processedTemplate;
}
error_log("🔧 ForStringProcessor: Loop processed, result length: " . strlen($result));
return $result;
return $this->processLoop($dataKey, $varName, $template, $context);
},
$content
);
// Then process old syntax
$patternOld = '/<for\s+var="([^"]+)"\s+in="([^"]+)"[^>]*>((?:(?!<for\s).)*?)<\/for>/s';
$content = preg_replace_callback(
$patternOld,
function ($matches) use ($context) {
$varName = $matches[1]; // var="..."
$dataKey = $matches[2]; // in="..."
$template = $matches[3];
error_log("🔧 ForStringProcessor: Processing OLD syntax loop - var='$varName', in='$dataKey'");
error_log("🔧 ForStringProcessor: Template content: " . substr($template, 0, 200));
return $this->processLoop($dataKey, $varName, $template, $context);
},
$content
);
return $content;
}
/**
* Process a single loop iteration
*/
private function processLoop(string $dataKey, string $varName, string $template, RenderContext $context): string
{
// Resolve the data array/collection
$data = $this->resolveValue($context->data, $dataKey);
if (! is_array($data) && ! is_iterable($data)) {
error_log("🔧 ForStringProcessor: Data for '$dataKey' is not iterable: " . gettype($data));
// Return empty string instead of original to remove the <for> tag
return '';
}
$result = '';
foreach ($data as $item) {
$processedTemplate = $this->replaceLoopVariables($template, $varName, $item);
$result .= $processedTemplate;
}
error_log("🔧 ForStringProcessor: Loop processed, result length: " . strlen($result));
return $result;
}
/**

View File

@@ -1,242 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\DomComponentService;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
use App\Framework\View\ValueObjects\ComponentMetadata;
use App\Framework\View\ValueObjects\HtmlElement;
use Dom\HTMLElement as DomHTMLElement;
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
final class FrameworkComponentProcessor implements DomProcessor
{
/** @var array<string, ComponentMetadata> */
private array $registry = [];
public function __construct(
private readonly DiscoveryRegistry $discoveryRegistry,
private readonly DomComponentService $componentService
) {
$this->discoverComponents();
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
// Process each registered component type
foreach ($this->registry as $tagName => $metadata) {
$elements = $dom->getElementsByTagName("x-{$tagName}");
$elements->forEach(function ($element) use ($dom, $tagName, $metadata) {
$this->processElement($dom, $element, $tagName, $metadata);
});
}
return $dom;
}
private function processElement(
DomWrapper $dom,
DomHTMLElement $element,
string $tagName,
ComponentMetadata $metadata
): void {
// Get content and attributes
$content = $element->textContent;
$attributes = $this->getElementAttributes($element);
// Render component
$rendered = $this->renderComponent($metadata, $content, $attributes);
// Replace element with rendered HTML using DomComponentService
$this->componentService->replaceComponent($dom, $element, $rendered);
}
/**
* @return array<string, string>
*/
private function getElementAttributes(DomHTMLElement $element): array
{
$attributes = [];
foreach ($element->attributes as $attr) {
$attributes[$attr->name] = $attr->value;
}
return $attributes;
}
/**
* @param array<string, string> $attributes
*/
private function renderComponent(
ComponentMetadata $metadata,
string $content,
array $attributes
): string {
$variant = $attributes['variant'] ?? null;
// Create component instance
$component = $this->instantiateComponent($metadata, $variant, $content, $attributes);
// Apply modifiers
$component = $this->applyModifiers($component, $attributes, $metadata);
return (string) $component;
}
/**
* @param array<string, string> $attributes
*/
private function instantiateComponent(
ComponentMetadata $metadata,
?string $variant,
string $content,
array $attributes
): HtmlElement {
// Try factory method first
if ($variant !== null && $metadata->hasFactory($variant)) {
$factory = $metadata->getFactory($variant);
return $factory->invoke(null, $content);
}
// Try default 'create' factory
if ($metadata->hasFactory('create')) {
$factory = $metadata->getFactory('create');
return $factory->invoke(null, $content);
}
// Fall back to constructor
$constructor = $metadata->reflection->getConstructor();
if ($constructor === null) {
throw new \RuntimeException("Component {$metadata->class} has no constructor or factory methods");
}
// Simple approach: pass content as first parameter
return $metadata->reflection->newInstance($content);
}
/**
* @param array<string, string> $attributes
*/
private function applyModifiers(
HtmlElement $component,
array $attributes,
ComponentMetadata $metadata
): HtmlElement {
foreach ($attributes as $name => $value) {
if ($name === 'variant') {
continue; // Already handled in instantiation
}
if ($metadata->hasModifier($name)) {
$modifier = $metadata->getModifier($name);
$params = $modifier->getParameters();
// If modifier has no parameters, call without arguments
if (empty($params)) {
$component = $modifier->invoke($component);
} else {
// Pass the attribute value
$component = $modifier->invoke($component, $value);
}
}
}
return $component;
}
private function discoverComponents(): void
{
$componentAttributes = $this->discoveryRegistry->attributes->get(ComponentName::class);
foreach ($componentAttributes as $attribute) {
$className = $attribute->className->getFullyQualified();
$reflection = new ReflectionClass($className);
/** @var ComponentName|null $componentName */
$componentName = $attribute->createAttributeInstance();
if ($componentName === null) {
continue;
}
$tagName = $componentName->tag;
$this->registry[$tagName] = new ComponentMetadata(
class: $className,
factories: $this->findFactoryMethods($reflection),
modifiers: $this->findModifierMethods($reflection),
reflection: $reflection
);
}
}
/**
* @return array<string, ReflectionMethod>
*/
private function findFactoryMethods(ReflectionClass $reflection): array
{
$factories = [];
foreach ($reflection->getMethods(ReflectionMethod::IS_STATIC | ReflectionMethod::IS_PUBLIC) as $method) {
$returnType = $method->getReturnType();
if (! $returnType instanceof ReflectionNamedType) {
continue;
}
if ($returnType->getName() === 'self' || $returnType->getName() === $reflection->getName()) {
$factories[$method->getName()] = $method;
}
}
return $factories;
}
/**
* @return array<string, ReflectionMethod>
*/
private function findModifierMethods(ReflectionClass $reflection): array
{
$modifiers = [];
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isStatic() || $method->isConstructor()) {
continue;
}
$returnType = $method->getReturnType();
if (! $returnType instanceof ReflectionNamedType) {
continue;
}
if ($returnType->getName() === 'self' || $returnType->getName() === $reflection->getName()) {
// Convert method name to kebab-case for HTML attributes
$attributeName = $this->methodNameToAttribute($method->getName());
$modifiers[$attributeName] = $method;
}
}
return $modifiers;
}
private function methodNameToAttribute(string $methodName): string
{
// Remove 'with' prefix if present
$name = preg_replace('/^with/', '', $methodName);
// Convert to kebab-case
return strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $name));
}
}

View File

@@ -1,97 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
use Dom\Element;
use Dom\HTMLDocument;
use Dom\HTMLElement;
final readonly class HoneypotProcessor implements DomProcessor
{
private const array HONEYPOT_NAMES = [
'email_confirm',
'website_url',
'phone_number',
'user_name',
'company_name',
];
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
error_log("HoneypotProcessor: Starting processing");
$forms = $dom->document->querySelectorAll('form');
error_log("HoneypotProcessor: Found " . count($forms) . " forms");
/** @var HTMLElement $form */
foreach ($forms as $form) {
error_log("HoneypotProcessor: Processing form");
$this->addHoneypot($dom->document, $form);
$this->addTimeValidation($dom->document, $form);
error_log("HoneypotProcessor: Form processed successfully");
}
error_log("HoneypotProcessor: Processing completed successfully");
return $dom;
}
private function addHoneypot(HTMLDocument $dom, Element $form): void
{
$honeypotName = self::HONEYPOT_NAMES[array_rand(self::HONEYPOT_NAMES)];
// Versteckter Container
$container = $dom->createElement('div');
$container->setAttribute('style', 'position:absolute;left:-9999px;visibility:hidden;');
$container->setAttribute('aria-hidden', 'true');
// Honeypot mit realistischem Label
$label = $dom->createElement('label');
$label->textContent = 'Website (optional)';
$label->setAttribute('for', $honeypotName);
$honeypot = $dom->createElement('input');
$honeypot->setAttribute('type', 'text');
$honeypot->setAttribute('name', $honeypotName);
$honeypot->setAttribute('id', $honeypotName);
$honeypot->setAttribute('autocomplete', 'off');
$honeypot->setAttribute('tabindex', '-1');
$container->appendChild($label);
$container->appendChild($honeypot);
// Honeypot-Name als verstecktes Feld
$nameField = $dom->createElement('input');
$nameField->setAttribute('type', 'hidden');
$nameField->setAttribute('name', '_honeypot_name');
$nameField->setAttribute('value', $honeypotName);
$firstChild = $form->firstChild;
if ($firstChild !== null) {
$form->insertBefore($container, $firstChild);
$form->insertBefore($nameField, $firstChild);
} else {
$form->appendChild($container);
$form->appendChild($nameField);
}
}
private function addTimeValidation(HTMLDocument $dom, Element $form): void
{
$timeField = $dom->createElement('input');
$timeField->setAttribute('type', 'hidden');
$timeField->setAttribute('name', '_form_start_time');
$timeField->setAttribute('value', (string)time());
$firstChild = $form->firstChild;
if ($firstChild !== null) {
$form->insertBefore($timeField, $firstChild);
} else {
$form->appendChild($timeField);
}
}
}

View File

@@ -1,157 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\DI\Container;
use App\Framework\Template\Parser\DomTemplateParser;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\RenderContext;
use Dom\Element;
final readonly class LayoutTagProcessor implements DomProcessor
{
public function __construct(
private TemplateLoader $loader,
private Container $container,
private DomTemplateParser $parser = new DomTemplateParser()
) {
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
error_log("🏗️🏗️🏗️ LayoutTagProcessor::process() CALLED - Template: " . $context->template);
error_log("LayoutTagProcessor: process() called");
if ($context->isPartial) {
error_log("LayoutTagProcessor: isPartial=true, removing layout tag");
return $this->removeLayoutTag($dom);
}
$layoutTag = $this->findLayoutTag($dom);
if (! $layoutTag) {
error_log("LayoutTagProcessor: No layout tag found");
return $dom;
}
error_log("LayoutTagProcessor: Layout tag found, processing");
return $this->applyLayout($layoutTag, $dom, $context);
}
private function findLayoutTag(DomWrapper $dom): ?Element
{
$dom = $dom->document;
// Try both 'name' and 'src' attributes for backward compatibility
$layoutTags = $dom->querySelectorAll('layout[name]');
if ($layoutTags->length === 0) {
$layoutTags = $dom->querySelectorAll('layout[src]');
}
return $layoutTags->length > 0 ? $layoutTags->item(0) : null;
}
private function applyLayout(Element $layoutTag, DomWrapper $dom, RenderContext $context): DomWrapper
{
// Support both 'name' and 'src' attributes
$layoutFile = $layoutTag->getAttribute('name') ?: $layoutTag->getAttribute('src');
$layoutPath = $this->loader->getTemplatePath($layoutFile);
error_log("LayoutTagProcessor: Processing layout: $layoutFile");
// Layout loading - ForProcessor will handle <for> tags after layout assembly
// Only process basic placeholders during layout loading
$placeholderReplacer = $this->container->get(PlaceholderReplacer::class);
$layoutContext = new RenderContext(
template: $layoutFile,
metaData: $context->metaData,
data: $context->data,
isPartial: false
);
$layoutContent = file_get_contents($layoutPath);
error_log("LayoutTagProcessor: Loaded layout content, length: " . strlen($layoutContent));
error_log("LayoutTagProcessor: Content contains '<for': " . (strpos($layoutContent, '<for') !== false ? 'YES' : 'NO'));
// Only process basic placeholders - ForProcessor will handle <for> tags later in DOM pipeline
error_log("LayoutTagProcessor: About to call PlaceholderReplacer->process()");
$processedLayoutHtml = $placeholderReplacer->process($layoutContent, $layoutContext);
error_log("LayoutTagProcessor: PlaceholderReplacer returned, final length: " . strlen($processedLayoutHtml));
$layoutDom = $this->parser->parseToWrapper($processedLayoutHtml);
$slot = $layoutDom->document->querySelector('main');
if (! $slot) {
return $dom; // Kein Slot verfügbar
}
// For self-closing layout tags, collect all content after the layout tag
if ($layoutTag->innerHTML === '') {
$contentToInsert = $this->collectContentAfterLayoutTag($layoutTag, $dom);
} else {
$contentToInsert = $layoutTag->innerHTML;
}
$slot->innerHTML = $contentToInsert;
return $layoutDom;
}
private function collectContentAfterLayoutTag(Element $layoutTag, DomWrapper $dom): string
{
error_log("LayoutTagProcessor: collectContentAfterLayoutTag started");
// Get all nodes after the layout tag
$content = '';
$currentNode = $layoutTag->nextSibling;
while ($currentNode !== null) {
if ($currentNode->nodeType === XML_ELEMENT_NODE) {
$content .= $dom->document->saveHTML($currentNode);
error_log("LayoutTagProcessor: Added element node: " . $currentNode->nodeName);
} elseif ($currentNode->nodeType === XML_TEXT_NODE) {
$textContent = trim($currentNode->textContent);
if ($textContent !== '') {
$content .= $textContent;
error_log("LayoutTagProcessor: Added text node: " . substr($textContent, 0, 100));
}
}
$currentNode = $currentNode->nextSibling;
}
error_log("LayoutTagProcessor: Collected content (first 500 chars): " . substr($content, 0, 500));
return $content;
}
private function removeLayoutTag(DomWrapper $dom): DomWrapper
{
$layoutTag = $this->findLayoutTag($dom);
if (! $layoutTag) {
return $dom;
}
// Den Inhalt des Layout-Tags bewahren und an dessen Stelle einfügen
$parent = $layoutTag->parentNode;
if ($parent) {
// Alle Kinder des Layout-Tags vor dem Layout-Tag selbst einfügen
while ($layoutTag->firstChild) {
$parent->insertBefore($layoutTag->firstChild, $layoutTag);
}
}
// Jetzt das leere Layout-Tag entfernen
$layoutTag->remove();
return $dom;
}
}

View File

@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\Meta\OpenGraphType;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
final readonly class MetaManipulator implements DomProcessor
{
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
$metaData = $context->metaData;
// Sichere head-Element-Erkennung
$head = $dom->getElementsByTagName('head')->first();
if (! $head) {
error_log("MetaManipulator: No head element found");
return $dom;
}
// Title setzen
if (! empty($metaData->title)) {
$titleElement = $head->getElementsByTagName('title')->item(0);
if ($titleElement) {
$titleElement->textContent = $metaData->title . " | Michael Schiemer";
} else {
// Title-Element erstellen falls nicht vorhanden
$title = $dom->document->createElement('title');
$title->textContent = $metaData->title . " | Michael Schiemer";
$head->appendChild($title);
}
}
// Description Meta-Tag setzen
if (! empty($metaData->description)) {
$descriptionMeta = $head->querySelector('meta[name="description"]');
if ($descriptionMeta) {
$descriptionMeta->setAttribute('content', $metaData->description);
} else {
// Description Meta-Tag erstellen
$meta = $dom->document->createElement('meta');
$meta->setAttribute('name', 'description');
$meta->setAttribute('content', $metaData->description);
$head->appendChild($meta);
}
}
// OpenGraph Type setzen
$ogType = match($metaData->openGraph->type) {
OpenGraphType::WEBSITE => 'website',
OpenGraphType::ARTICLE => 'article',
default => 'website',
};
$ogTypeMeta = $head->querySelector('meta[property="og:type"]');
if ($ogTypeMeta) {
$ogTypeMeta->setAttribute('content', $ogType);
} else {
// OG Type Meta-Tag erstellen
$meta = $dom->document->createElement('meta');
$meta->setAttribute('property', 'og:type');
$meta->setAttribute('content', $ogType);
$head->appendChild($meta);
}
return $dom;
}
}

View File

@@ -6,8 +6,11 @@ namespace App\Framework\View\Processors;
use App\Framework\Config\AppConfig;
use App\Framework\DI\Container;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\Template\Processing\StringProcessor;
use App\Framework\View\Functions\ImageSlotFunction;
use App\Framework\View\Functions\LazyComponentFunction;
use App\Framework\View\Functions\UrlFunction;
use App\Framework\View\RawHtml;
use App\Framework\View\RenderContext;
@@ -19,6 +22,7 @@ final class PlaceholderReplacer implements StringProcessor
{
public function __construct(
private readonly Container $container,
private readonly ComponentRegistry $componentRegistry
) {
}
@@ -37,9 +41,22 @@ final class PlaceholderReplacer implements StringProcessor
// Template-Funktionen: {{ date('Y-m-d') }}, {{ format_currency(100) }}
$html = $this->replaceTemplateFunctions($html, $context);
// Standard Variablen und Methoden: {{ item.getRelativeFile() }}
// Triple curly braces for raw/unescaped HTML: {{{ $content }}} or {{{ content }}}
// Supports both old and new syntax for backwards compatibility
$html = preg_replace_callback(
'/{{{\\s*\\$?([\\w.]+)\\s*}}}/',
function ($matches) use ($context) {
$expression = $matches[1];
return $this->resolveRaw($context->data, $expression);
},
$html
);
// Standard Variablen und Methoden: {{ $item.getRelativeFile() }} or {{ item.getRelativeFile() }}
// Supports both old and new syntax for backwards compatibility
return preg_replace_callback(
'/{{\\s*([\\w.]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
'/{{\\s*\\$?([\\w.]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
function ($matches) use ($context) {
$expression = $matches[1];
$params = isset($matches[2]) ? trim($matches[2]) : null;
@@ -62,7 +79,7 @@ final class PlaceholderReplacer implements StringProcessor
$functionName = $matches[1];
$params = trim($matches[2]);
$functions = new TemplateFunctions($this->container, ImageSlotFunction::class, UrlFunction::class);
$functions = new TemplateFunctions($this->container, ImageSlotFunction::class, LazyComponentFunction::class, UrlFunction::class);
if ($functions->has($functionName)) {
$function = $functions->get($functionName);
$args = $this->parseParams($params, $context->data);
@@ -166,6 +183,46 @@ final class PlaceholderReplacer implements StringProcessor
}
}
private function resolveRaw(array $data, string $expr): string
{
$value = $this->resolveValue($data, $expr);
if ($value === null) {
// Bleibt als Platzhalter stehen
return '{{{ ' . $expr . ' }}}';
}
// LiveComponentContract - automatisch rendern mit Wrapper
if ($value instanceof LiveComponentContract) {
return $this->componentRegistry->renderWithWrapper($value);
}
// RawHtml-Objekte - direkt ausgeben
if ($value instanceof RawHtml) {
return $value->content;
}
// HtmlElement-Objekte - direkt ausgeben
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
return (string) $value;
}
// Strings direkt ausgeben (KEIN escaping!)
if (is_string($value)) {
return $value;
}
// Arrays und komplexe Objekte können nicht direkt als String dargestellt werden
if (is_array($value)) {
return $this->handleArrayValue($expr, $value);
}
if (is_object($value) && ! method_exists($value, '__toString')) {
return $this->handleObjectValue($expr, $value);
}
return (string)$value;
}
private function resolveEscaped(array $data, string $expr, int $flags): string
{
$value = $this->resolveValue($data, $expr);
@@ -174,6 +231,14 @@ final class PlaceholderReplacer implements StringProcessor
return '{{ ' . $expr . ' }}';
}
// LiveComponentContract - automatisch rendern mit Wrapper
// WICHTIG: Wrapper-HTML ist bereits escaped, also NICHT nochmal escapen!
if ($value instanceof LiveComponentContract) {
$wrapperHtml = $this->componentRegistry->renderWithWrapper($value);
// Wrap in RawHtml to prevent double-escaping by code below (line 271)
return $wrapperHtml;
}
// RawHtml-Objekte nicht escapen
if ($value instanceof RawHtml) {
return $value->content;

View File

@@ -4,23 +4,106 @@ declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\LiveComponents\Contracts\SupportsSlots;
use App\Framework\LiveComponents\SlotManager;
use App\Framework\LiveComponents\ValueObjects\SlotContent;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
/*
<component name="card">
<slot name="header">Default Header</slot>
<slot>Main Content</slot>
</component>
*/
/**
* Slot Processor for Template System
*
* Processes slot tags in templates and resolves them using the SlotManager.
*
* Syntax:
* - <slot name="header">Default content</slot>
* - <slot>Default slot content</slot>
* - <slot name="content" scope="true">Scoped content with {context.key}</slot>
*
* Features:
* - Named slots (header, footer, sidebar, etc.)
* - Default (unnamed) slots
* - Scoped slots with context injection
* - Default content fallbacks
* - Validation of required slots
* - Integration with SlotManager and SupportsSlots
*
* Example:
* ```html
* <component name="card">
* <slot name="header">Default Header</slot>
* <slot>Main Content</slot>
* </component>
* ```
*/
final readonly class SlotProcessor implements DomProcessor
{
public function __construct(
private SlotManager $slotManager
) {
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
// Check if component supports the new Slot System
$component = $context->component ?? null;
if ($component instanceof SupportsSlots) {
return $this->processWithSlotSystem($dom, $context, $component);
}
// Fallback to legacy slot processing
return $this->processLegacySlots($dom, $context);
}
/**
* Process slots using the new Slot System with SlotManager
*/
private function processWithSlotSystem(
DomWrapper $dom,
RenderContext $context,
SupportsSlots $component
): DomWrapper {
// Extract provided slots from context
$providedSlots = $this->extractProvidedSlotsFromContext($context);
// Validate slots
$errors = $this->slotManager->validateSlots($component, $providedSlots);
if (! empty($errors)) {
$this->handleValidationErrors($errors, $context);
}
// Process each slot element in the template
foreach ($dom->querySelectorAll('slot') as $slotNode) {
$slotName = $slotNode->getAttribute('name') ?: 'default';
// Find slot definition
$definition = $this->slotManager->getSlotDefinition($component, $slotName);
if ($definition === null) {
// Unknown slot, keep default content
continue;
}
// Resolve slot content using SlotManager
$resolvedContent = $this->slotManager->resolveSlotContent(
component: $component,
definition: $definition,
providedContents: $providedSlots
);
// Replace slot node with resolved content
$this->replaceSlotNode($dom, $slotNode, $resolvedContent);
}
return $dom;
}
/**
* Legacy slot processing for backward compatibility
*/
private function processLegacySlots(DomWrapper $dom, RenderContext $context): DomWrapper
{
foreach ($dom->querySelectorAll('slot[name]') as $slotNode) {
$slotName = $slotNode->getAttribute('name');
@@ -42,4 +125,69 @@ final readonly class SlotProcessor implements DomProcessor
return $dom;
}
/**
* Extract SlotContent objects from RenderContext
*
* @return array<SlotContent>
*/
private function extractProvidedSlotsFromContext(RenderContext $context): array
{
$slots = [];
// Check if context has slot data (legacy format)
if (isset($context->slots) && is_array($context->slots)) {
foreach ($context->slots as $name => $content) {
$slots[] = SlotContent::named(
slotName: $name,
content: $content
);
}
}
// Check if context has SlotContent objects (new format)
if (isset($context->slotContents) && is_array($context->slotContents)) {
foreach ($context->slotContents as $slotContent) {
if ($slotContent instanceof SlotContent) {
$slots[] = $slotContent;
}
}
}
return $slots;
}
/**
* Replace slot node with resolved content
*/
private function replaceSlotNode(DomWrapper $dom, \DOMElement $slotNode, string $content): void
{
$replacement = $dom->createDocumentFragment();
if (! empty($content)) {
@$replacement->appendXML($content);
}
$slotNode->parentNode?->replaceChild($replacement, $slotNode);
}
/**
* Handle slot validation errors
*/
private function handleValidationErrors(array $errors, RenderContext $context): void
{
$errorMessage = "Slot validation failed:\n" . implode("\n", $errors);
// In development, we might want to show errors
// In production, log them
$isDevelopment = ($context->environment ?? 'production') === 'development';
if ($isDevelopment) {
// Store errors in context for debugging
$context->slotValidationErrors = $errors;
}
// Always log errors
error_log($errorMessage);
}
}

View File

@@ -0,0 +1,376 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\LiveComponents\Contracts\ComponentRegistryInterface;
use App\Framework\LiveComponents\Performance\ComponentMetadataCacheInterface;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomComponentService;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
use Dom\HTMLElement;
/**
* XComponentProcessor - Unified component syntax processor
*
* Handles BOTH LiveComponents AND StaticComponents with <x-*> syntax:
*
* LiveComponents (Interactive/Stateful):
* <x-datatable id="users" page="1" pageSize="25" />
* ↓
* <div data-component-id="datatable:users" data-component-state="...">...</div>
*
* StaticComponents (Server-rendered):
* <x-button variant="primary">Click me</x-button>
* ↓
* <button class="btn btn--primary">Click me</button>
*
* Auto-Detection Logic:
* 1. Check if component with #[LiveComponent] or #[ComponentName] exists in ComponentRegistry
* 2. LiveComponent → Render with state wrapper
* 3. StaticComponent → Render via ComponentRegistry.renderStatic()
* 4. Neither → Error with helpful message
*
* Framework Pattern: readonly class, composition over inheritance
*/
final readonly class XComponentProcessor implements DomProcessor
{
public function __construct(
private ComponentRegistryInterface $componentRegistry,
private ComponentMetadataCacheInterface $metadataCache,
private DomComponentService $componentService
) {
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
// Find all <x-*> elements (e.g., <x-datatable>, <x-button>)
$xComponents = $this->findXComponents($dom);
foreach ($xComponents as $element) {
try {
$this->processXComponent($dom, $element, $context);
} catch (\Throwable $e) {
// Replace with error message in development, silently fail in production
$this->handleProcessingError($dom, $element, $e);
}
}
return $dom;
}
/**
* Find all <x-*> elements using CSS selectors
*
* @return list<HTMLElement>
*/
private function findXComponents(DomWrapper $dom): array
{
// Use modern CSS selector API - much cleaner than XPath
// This will match all elements starting with "x-"
#$nodes = $dom->document->querySelectorAll('[class*="x-"], x-counter, x-datatable, x-search, x-button, x-badge, x-card, x-timer, x-chart, x-tabs, x-modal, x-notification-center, x-shopping-cart, x-product-filter, x-autocomplete, x-dynamic-form, x-infinite-scroll, x-comment-thread, x-activity-feed, x-metrics-dashboard, x-live-presence, x-image-uploader');
// Better: iterate through all elements and check tagName
$xComponents = [];
$allElements = $dom->document->getElementsByTagName('*');
foreach ($allElements as $element) {
if ($element instanceof HTMLElement && str_starts_with(strtolower($element->tagName), 'x-')) {
$xComponents[] = $element;
}
}
return $xComponents;
}
/**
* Process single <x-*> component with auto-detection
*/
private function processXComponent(
DomWrapper $dom,
HTMLElement $element,
RenderContext $context
): void {
// Extract component name: <x-datatable> → "datatable"
$componentName = $this->extractComponentName($element);
// Check if component is registered
if (!$this->componentRegistry->isRegistered($componentName)) {
throw new \InvalidArgumentException(
"Unknown component: <x-{$componentName}>\n\n" .
"Available components: " . implode(', ', $this->componentRegistry->getAllComponentNames())
);
}
// Get component class name to determine type
$className = $this->componentRegistry->getClassName($componentName);
if ($className === null) {
throw new \InvalidArgumentException("Component '{$componentName}' class not found");
}
// Check if it's a LiveComponent (has state) or StaticComponent
$metadata = $this->metadataCache->get($className);
$isLiveComponent = isset($metadata->state);
if ($isLiveComponent) {
// Process as LiveComponent (Interactive/Stateful)
$this->processAsLiveComponent($dom, $element, $componentName);
} else {
// Process as StaticComponent (Server-rendered)
$this->processAsStaticComponent($dom, $element, $componentName);
}
}
/**
* Process as LiveComponent (Interactive)
*/
private function processAsLiveComponent(
DomWrapper $dom,
HTMLElement $element,
string $componentName
): 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);
// Create ComponentData from props
$initialState = ComponentData::fromArray($props);
// Resolve component via ComponentRegistry
$component = $this->componentRegistry->resolve($componentId, $initialState);
// Render component with wrapper (for initial page load)
$componentHtml = $this->componentRegistry->renderWithWrapper($component);
// Replace <x-datatable> with rendered component HTML
$this->componentService->replaceComponent($dom, $element, $componentHtml);
}
/**
* Process as StaticComponent (Server-rendered)
*/
private function processAsStaticComponent(
DomWrapper $dom,
HTMLElement $element,
string $componentName
): void {
// Get element content and attributes
$content = $element->textContent ?? '';
$attributes = $this->extractAttributesAsArray($element);
// Render via ComponentRegistry.renderStatic()
$rendered = $this->componentRegistry->renderStatic(
$componentName,
$content,
$attributes
);
// Replace element with rendered HTML
$this->componentService->replaceComponent($dom, $element, $rendered);
}
/**
* Extract component name from tag: <x-datatable> → "datatable"
*/
private function extractComponentName(HTMLElement $element): string
{
$tagName = strtolower($element->tagName);
if (! str_starts_with($tagName, 'x-')) {
throw new \InvalidArgumentException(
"Invalid x-component tag: '{$tagName}' (must start with 'x-')"
);
}
return substr($tagName, 2); // Remove "x-" prefix
}
/**
* Extract props from HTML attributes with type coercion (for LiveComponents)
*
* @return array<string, mixed>
*/
private function extractProps(HTMLElement $element): array
{
$props = [];
foreach ($element->attributes as $attr) {
$name = $attr->nodeName;
$value = $attr->nodeValue ?? '';
// Type coercion: "123" → 123, "true" → true, "[1,2]" → [1,2]
$props[$name] = $this->coerceType($value);
}
return $props;
}
/**
* Extract attributes as array (for StaticComponents)
*
* @return array<string, string>
*/
private function extractAttributesAsArray(HTMLElement $element): array
{
$attributes = [];
foreach ($element->attributes as $attr) {
$attributes[$attr->nodeName] = $attr->nodeValue ?? '';
}
return $attributes;
}
/**
* Type coercion for prop values (LiveComponents only)
*
* Examples:
* - "123" → 123 (int)
* - "12.5" → 12.5 (float)
* - "true" → true (bool)
* - "false" → false (bool)
* - "null" → null
* - "[1,2,3]" → [1,2,3] (array via JSON)
* - '{"key":"value"}' → ["key" => "value"] (object via JSON)
* - "text" → "text" (string fallback)
*/
private function coerceType(string $value): mixed
{
// Boolean literals
if ($value === 'true') {
return true;
}
if ($value === 'false') {
return false;
}
// Null literal
if ($value === 'null') {
return null;
}
// Numeric values
if (is_numeric($value)) {
return str_contains($value, '.') ? (float)$value : (int)$value;
}
// JSON arrays or objects: "[1,2,3]" or '{"key":"value"}'
if (str_starts_with($value, '[') || str_starts_with($value, '{')) {
try {
$decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
return $decoded;
} catch (\JsonException) {
// Not valid JSON, treat as string
}
}
// Fallback: string
return $value;
}
/**
* Validate props against ComponentMetadata (LiveComponents only)
*
* @param array<string, mixed> $props
*/
private function validateLiveComponentProps(string $componentName, array $props): void
{
// Get component class name from registry
$className = $this->componentRegistry->getClassName($componentName);
if ($className === null) {
return; // Component not found - will be caught in processXComponent
}
// Get metadata from cache
$metadata = $this->metadataCache->get($className);
// Validate each prop exists in component
foreach ($props as $propName => $value) {
// Skip 'id' - it's used for ComponentId, not a component property
if ($propName === 'id') {
continue;
}
if (! $metadata->hasProperty($propName)) {
throw new \InvalidArgumentException(
"LiveComponent '{$componentName}' has no property '{$propName}'. " .
"Available properties: " . implode(', ', $this->getPropertyNames($metadata))
);
}
}
}
/**
* Get property names from metadata
*
* @param object{properties?: array<string, mixed>} $metadata
* @return array<int, string>
*/
private function getPropertyNames(object $metadata): array
{
$properties = $metadata->properties ?? [];
return array_keys($properties);
}
/**
* Generate unique instance ID for component
*/
private function generateInstanceId(string $componentName): string
{
return $componentName . '-' . bin2hex(random_bytes(4));
}
/**
* Handle processing errors
*
* In development: Show error message
* In production: Silently fail (or log)
*/
private function handleProcessingError(
DomWrapper $dom,
HTMLElement $element,
\Throwable $e
): void {
// Check if we're in debug mode (via environment or config)
$isDebug = ($_ENV['APP_ENV'] ?? 'production') === 'development';
if ($isDebug) {
// Show error message in place of component
$errorHtml = sprintf(
'<div style="border:2px solid red;padding:1rem;background:#fee;color:#c00;">' .
'<strong>XComponentProcessor Error:</strong><br>' .
'<pre>%s</pre>' .
'<small>Component: %s</small>' .
'</div>',
htmlspecialchars($e->getMessage()),
htmlspecialchars($element->tagName)
);
$this->componentService->replaceComponent($dom, $element, $errorHtml);
} else {
// Production: Remove element silently (or log error)
$element->remove();
// Optional: Log error for monitoring
// error_log("XComponentProcessor error: " . $e->getMessage());
}
}
}

View File

@@ -17,7 +17,8 @@ final readonly class RenderContext implements TemplateContext
public array $slots = [], // Benannte Slots wie ['main' => '<p>...</p>']
public ?string $controllerClass = null,
public ?string $route = null, // Route name for form ID generation
public bool $isPartial = false,
public ProcessingMode $processingMode = ProcessingMode::FULL,
public bool $isPartial = false, // Deprecated: Use processingMode instead
) {
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\View\Contracts\StaticComponent;
use App\Framework\View\Dom\Renderer\HtmlRenderer;
/**
* StaticComponentRenderer
*
* Renders StaticComponents to HTML strings.
* Counterpart to LiveComponentRenderer for server-side static components.
*
* Rendering Flow:
* 1. Instantiate StaticComponent with content + attributes
* 2. Component returns Node tree via getRootNode()
* 3. HtmlRenderer converts tree to HTML string
*
* Example:
* ```php
* $renderer = new StaticComponentRenderer($htmlRenderer);
*
* $html = $renderer->render(
* componentClass: Button::class,
* content: 'Click Me',
* attributes: ['variant' => 'primary']
* );
* // Returns: <button class="btn btn-primary">Click Me</button>
* ```
*/
final readonly class StaticComponentRenderer
{
public function __construct(
private HtmlRenderer $htmlRenderer = new HtmlRenderer()
) {
}
/**
* Render a StaticComponent to HTML
*
* @param class-string<StaticComponent> $componentClass Fully qualified component class name
* @param string $content Inner HTML content from template
* @param array<string, string> $attributes Key-value attributes from template
* @return string Rendered HTML
* @throws \RuntimeException If class does not implement StaticComponent
*/
public function render(string $componentClass, string $content, array $attributes = []): string
{
// Verify it's a StaticComponent
if (! is_subclass_of($componentClass, StaticComponent::class)) {
throw new \RuntimeException(
"Class {$componentClass} does not implement StaticComponent interface"
);
}
// Instantiate StaticComponent with content + attributes
$component = new $componentClass($content, $attributes);
// Get root node tree from component
$rootNode = $component->getRootNode();
// Render tree to HTML string
return $this->htmlRenderer->render($rootNode);
}
}

View File

@@ -5,104 +5,189 @@ declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\DI\Container;
use App\Framework\Template\Processing\ProcessorResolver;
use App\Framework\Template\Processing\AstTransformer;
use App\Framework\Template\Processing\StringProcessor;
use App\Framework\View\Processing\DomProcessingPipeline;
use Dom\HTMLDocument;
use App\Framework\View\Dom\Parser\HtmlParser;
use App\Framework\View\Dom\Renderer\HtmlRenderer;
use App\Framework\View\Processing\AstProcessingPipeline;
final class TemplateProcessor
{
private array $resolvedProcessors = [];
private array $resolvedStringProcessors = [];
private array $resolvedAstTransformers = [];
private ?DomProcessingPipeline $domPipeline = null;
private ?ProcessorResolver $processorResolver = null;
private ?AstProcessingPipeline $astPipeline = null;
private ?HtmlParser $parser = null;
private ?HtmlRenderer $renderer = null;
public function __construct(
private readonly array $domProcessors,
private readonly array $astTransformers,
private readonly array $stringProcessors,
private readonly Container $container
private readonly Container $container,
private readonly ?ProcessorChainOptimizer $chainOptimizer = null,
private readonly ?CompiledTemplateCache $compiledTemplateCache = null,
private readonly ?ProcessorPerformanceTracker $performanceTracker = null
) {
}
public function render(RenderContext $context, string $html, bool $component = false): string
{
error_log("TemplateProcessor::render ENTRY - Template: " . $context->template);
error_log("TemplateProcessor::render ENTRY - Component flag: " . ($component ? 'true' : 'false'));
// CRITICAL FIX: Process strings FIRST to handle <for> tags before DOM parsing removes them
error_log("Starting string processing FIRST...");
// Step 1: Process strings FIRST (handles <for> loops, placeholders, etc.)
$processedHtml = $this->processString($html, $context);
// Skip DOM processing if no DOM processors configured
if (empty($this->domProcessors)) {
error_log("No DOM processors - string processing complete");
// Step 2: Determine which AST transformers to use based on processing mode
$transformersToUse = $this->getTransformersForMode($context->processingMode);
// Skip AST processing if no transformers or MINIMAL mode
if (empty($transformersToUse) || $context->processingMode === ProcessingMode::MINIMAL) {
return $processedHtml;
}
// Lazy initialize DOM pipeline
if ($this->domPipeline === null) {
$this->processorResolver ??= $this->container->get(ProcessorResolver::class);
$this->domPipeline = new DomProcessingPipeline($this->domProcessors, $this->processorResolver);
error_log("DOM pipeline initialized with processors: " . implode(', ', $this->domProcessors));
// Step 3: Process through AST pipeline
$this->initializeAstPipeline($transformersToUse);
$document = $this->astPipeline->process($context, $processedHtml);
// Step 4: Render AST back to HTML
$this->renderer ??= new HtmlRenderer();
$finalHtml = $this->renderer->render($document);
// Step 5: Extract body content if component mode
if ($component) {
$finalHtml = $this->extractBodyContent($finalHtml);
}
error_log("Starting DOM processing AFTER string processing...");
$dom = $this->domPipeline->process($context, $processedHtml);
$finalHtml = $component
? $this->extractBodyContent($dom->document)
: $dom->document->saveHTML();
error_log("Template processing complete for: " . $context->template);
return $finalHtml;
}
/**
* Get AST transformers based on processing mode
*/
private function getTransformersForMode(ProcessingMode $mode): array
{
return match ($mode) {
ProcessingMode::FULL => $this->astTransformers,
ProcessingMode::COMPONENT => $this->filterComponentTransformers($this->astTransformers),
ProcessingMode::MINIMAL => [],
};
}
/**
* Filter transformers for component mode
* (similar to COMPONENT_ALLOWED_PROCESSORS logic)
*/
private function filterComponentTransformers(array $transformers): array
{
// For now, allow all transformers in component mode
// This can be refined later based on requirements
return $transformers;
}
/**
* Initialize AST pipeline with resolved transformer instances
*/
private function initializeAstPipeline(array $transformerClasses): void
{
$transformerInstances = [];
foreach ($transformerClasses as $transformerClass) {
$transformerInstances[] = $this->resolveAstTransformer($transformerClass);
}
$this->parser ??= new HtmlParser();
$this->astPipeline = new AstProcessingPipeline($transformerInstances, $this->parser);
}
public function __debugInfo(): ?array
{
return [
$this->domProcessors,
$this->stringProcessors,
'astTransformers' => $this->astTransformers,
'stringProcessors' => $this->stringProcessors,
];
}
// Removed unused processDom method - using DomProcessingPipeline instead
private function processString(string $html, RenderContext $context): string
{
error_log("TemplateProcessor::processString started");
// Optimize processor chain if optimizer available
$processors = $this->chainOptimizer !== null
? $this->optimizeProcessorChain($this->stringProcessors, $html)
: $this->stringProcessors;
// Verarbeitung durch String-Prozessoren
foreach ($this->stringProcessors as $processorClass) {
error_log("Processing with string processor: " . $processorClass);
$processor = $this->resolveProcessor($processorClass);
$beforeHtml = substr($html, 0, 100) . '...';
$html = $processor->process($html, $context);
$afterHtml = substr($html, 0, 100) . '...';
error_log("Before: $beforeHtml");
error_log("After: $afterHtml");
// Process through String processors (optimized order)
foreach ($processors as $processorClass) {
$processor = $this->resolveStringProcessor($processorClass);
// Optional: Performance Tracking
if ($this->performanceTracker !== null) {
$html = $this->performanceTracker->measure(
$processorClass,
fn () => $processor->process($html, $context)
);
} else {
$html = $processor->process($html, $context);
}
}
error_log("TemplateProcessor::processString completed");
return $html;
}
/** @param class-string<StringProcessor> $processorClass */
public function resolveProcessor(string $processorClass): StringProcessor
/**
* Optimize processor chain based on template content
*
* @param array<string> $processors Processor class names
* @param string $html Template content
* @return array<string> Optimized processor class names
*/
private function optimizeProcessorChain(array $processors, string $html): array
{
if (! isset($this->resolvedProcessors[$processorClass])) {
$this->resolvedProcessors[$processorClass] = $this->container->get($processorClass);
;
if ($this->chainOptimizer === null) {
return $processors;
}
return $this->resolvedProcessors[$processorClass];
// Convert class names to processor instances for optimization
$processorInstances = array_map(
fn (string $class) => $this->resolveStringProcessor($class),
$processors
);
// Optimize and get back optimized instances
$optimizedInstances = $this->chainOptimizer->optimize($processorInstances, $html);
// Convert back to class names
return array_map(
fn ($processor) => $processor::class,
$optimizedInstances
);
}
private function extractBodyContent(HTMLDocument $dom): string
/** @param class-string<StringProcessor> $processorClass */
public function resolveStringProcessor(string $processorClass): StringProcessor
{
return $dom->body->innerHTML ?? '';
if (! isset($this->resolvedStringProcessors[$processorClass])) {
$this->resolvedStringProcessors[$processorClass] = $this->container->get($processorClass);
}
return $this->resolvedStringProcessors[$processorClass];
}
/** @param class-string<AstTransformer> $transformerClass */
private function resolveAstTransformer(string $transformerClass): AstTransformer
{
if (! isset($this->resolvedAstTransformers[$transformerClass])) {
$this->resolvedAstTransformers[$transformerClass] = $this->container->get($transformerClass);
}
return $this->resolvedAstTransformers[$transformerClass];
}
private function extractBodyContent(string $html): string
{
// Extract content between <body> and </body> tags
if (preg_match('/<body[^>]*>(.*?)<\/body>/is', $html, $matches)) {
return $matches[1];
}
// Fallback: return full HTML if no body tag found
return $html;
}
}

View File

@@ -10,69 +10,76 @@ use App\Framework\DI\DefaultContainer;
use App\Framework\DI\Initializer;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Performance\PerformanceService;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\Template\Processing\StringProcessor;
use App\Framework\View\Dom\Transformer\AssetInjectorTransformer;
use App\Framework\View\Dom\Transformer\CommentStripTransformer;
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\WhitespaceCleanupTransformer;
use App\Framework\View\Dom\Transformer\XComponentTransformer;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\Processors\AssetInjector;
use App\Framework\View\Processors\CommentStripProcessor;
use App\Framework\View\Processors\ComponentProcessor;
use App\Framework\View\Processors\FormProcessor;
use App\Framework\View\Processors\ForProcessor;
use App\Framework\View\Processors\FrameworkComponentProcessor;
use App\Framework\View\Processors\HoneypotProcessor;
use App\Framework\View\Processors\IfProcessor;
use App\Framework\View\Processors\LayoutTagProcessor;
use App\Framework\View\Processors\MetaManipulator;
use App\Framework\View\Processors\ForStringProcessor;
use App\Framework\View\Processors\PlaceholderReplacer;
use App\Framework\View\Processors\RemoveEmptyLinesProcessor;
use App\Framework\View\Processors\SingleLineHtmlProcessor;
use App\Framework\View\Processors\VoidElementsSelfClosingProcessor;
use App\Framework\View\Table\TableProcessor;
final readonly class TemplateRendererInitializer
{
public function __construct(
private DefaultContainer $container,
private DiscoveryRegistry $results,
) {
}
) {}
#[Initializer]
public function __invoke(): TemplateRenderer
{
$doms = [
IfProcessor::class, // IfProcessor runs early for conditional content
ComponentProcessor::class, // ComponentProcessor runs before layout assembly
FrameworkComponentProcessor::class, // FrameworkComponentProcessor handles <x-*> tags for PHP components
LayoutTagProcessor::class, // Layout processing loads layout templates
TableProcessor::class, // TableProcessor handles <table-data> tags for structured tables
ForProcessor::class, // ForProcessor handles all <for> tags after layout assembly
MetaManipulator::class,
AssetInjector::class,
CommentStripProcessor::class,
RemoveEmptyLinesProcessor::class,
FormProcessor::class,
HoneypotProcessor::class,
// AST Transformers (new approach)
$astTransformers = [
// Core transformers (order matters!)
LayoutTagTransformer::class, // Process <layout> tags FIRST (before other processing)
XComponentTransformer::class, // Process <x-*> components (LiveComponents + HtmlComponents)
IfTransformer::class, // Conditional rendering (if/condition attributes)
MetaManipulatorTransformer::class, // Set meta tags from context
AssetInjectorTransformer::class, // Inject Vite assets (CSS/JS)
HoneypotTransformer::class, // Add honeypot spam protection to forms
CommentStripTransformer::class, // Remove HTML comments
WhitespaceCleanupTransformer::class, // Remove empty text nodes
];
// TODO: Migrate remaining DOM processors to AST transformers:
// - ComponentProcessor (for <component> tags) - COMPLEX, keep in DOM for now
// - TableProcessor (for table rendering) - OPTIONAL
// - ForProcessor (DOM-based, we already have ForStringProcessor) - HANDLED
// - FormProcessor (for form handling) - OPTIONAL
$strings = [
ForStringProcessor::class, // ForStringProcessor MUST run first to process <for> loops before DOM parsing
PlaceholderReplacer::class, // PlaceholderReplacer handles simple {{ }} replacements
#SingleLineHtmlProcessor::class,
VoidElementsSelfClosingProcessor::class,
# CsrfReplaceProcessor::class, // DEACTIVATED - FormDataResponseMiddleware handles form processing
];
/*$domImplementations = [];
foreach ($this->results->interfaces->get(DomProcessor::class) as $className) {
$domImplementations[] = $className->getFullyQualified();
/** @var Cache $cache */
$cache = $this->container->get(Cache::class);
// Performance-Optimierungen (optional)
$chainOptimizer = new ProcessorChainOptimizer($cache);
$compiledTemplateCache = new CompiledTemplateCache($cache);
// Performance Tracker nur in Development/Profiling
$performanceTracker = null;
if (getenv('ENABLE_TEMPLATE_PROFILING') === 'true') {
$performanceTracker = new ProcessorPerformanceTracker();
$performanceTracker->enable();
}
$stringImplementations = [];
foreach ($this->results->interfaces->get(StringProcessor::class) as $className) {
$stringImplementations[] = $className->getFullyQualified();
}*/
$templateProcessor = new TemplateProcessor($doms, $strings, $this->container);
$templateProcessor = new TemplateProcessor(
astTransformers: $astTransformers,
stringProcessors: $strings,
container: $this->container,
chainOptimizer: $chainOptimizer,
compiledTemplateCache: $compiledTemplateCache,
performanceTracker: $performanceTracker
);
$this->container->singleton(TemplateProcessor::class, $templateProcessor);

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\ValueObjects;
use ReflectionClass;
final readonly class ComponentMetadata
{
/**
* @param class-string<HtmlElement> $class
* @param array<string, \ReflectionMethod> $factories
* @param array<string, \ReflectionMethod> $modifiers
*/
public function __construct(
public string $class,
public array $factories,
public array $modifiers,
public ReflectionClass $reflection
) {
}
public function hasFactory(string $name): bool
{
return isset($this->factories[$name]);
}
public function hasModifier(string $name): bool
{
return isset($this->modifiers[$name]);
}
public function getFactory(string $name): ?\ReflectionMethod
{
return $this->factories[$name] ?? null;
}
public function getModifier(string $name): ?\ReflectionMethod
{
return $this->modifiers[$name] ?? null;
}
}

View File

@@ -11,7 +11,7 @@ final readonly class FormElement implements HtmlElement
public HtmlAttributes $attributes,
public string $content = ''
) {
if (!$this->tag->isFormElement()) {
if (! $this->tag->isFormElement()) {
throw new \InvalidArgumentException("Tag {$this->tag} is not a form element");
}
}
@@ -70,24 +70,28 @@ final readonly class FormElement implements HtmlElement
public static function submitButton(string $text = 'Submit'): self
{
$attributes = HtmlAttributes::empty()->withType('submit');
return new self(HtmlTag::button(), $attributes, $text);
}
public static function button(string $text, string $type = 'button'): self
{
$attributes = HtmlAttributes::empty()->withType($type);
return new self(HtmlTag::button(), $attributes, $text);
}
public static function label(string $text, string $for = ''): self
{
$attributes = $for ? HtmlAttributes::empty()->with('for', $for) : HtmlAttributes::empty();
return new self(HtmlTag::label(), $attributes, $text);
}
public static function textarea(string $name, string $content = ''): self
{
$attributes = HtmlAttributes::empty()->withName($name);
return new self(HtmlTag::textarea(), $attributes, $content);
}
@@ -128,4 +132,4 @@ final readonly class FormElement implements HtmlElement
return "<{$tagName}{$attributesPart}>{$this->content}</{$tagName}>";
}
}
}

View File

@@ -15,7 +15,7 @@ final readonly class FormId
throw new \InvalidArgumentException('FormId cannot be empty');
}
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $value)) {
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $value)) {
throw new \InvalidArgumentException('FormId must start with a letter and contain only letters, numbers, underscores, and hyphens');
}
}
@@ -24,6 +24,7 @@ final readonly class FormId
{
// Use the existing FormIdGenerator logic
$formId = $generator->generateFormId($route, $method);
return new self($formId);
}
@@ -51,4 +52,4 @@ final readonly class FormId
{
return $generator->isValidFormId($this);
}
}
}

View File

@@ -11,7 +11,8 @@ final readonly class HtmlAttributes
*/
public function __construct(
public array $attributes = []
) {}
) {
}
public static function empty(): self
{
@@ -128,4 +129,4 @@ final readonly class HtmlAttributes
return implode(' ', $parts);
}
}
}

View File

@@ -7,8 +7,10 @@ namespace App\Framework\View\ValueObjects;
interface HtmlElement
{
public HtmlTag $tag { get; }
public HtmlAttributes $attributes { get; }
public string $content { get; }
public function __toString(): string;
}
}

View File

@@ -8,7 +8,8 @@ final readonly class HtmlTag
{
public function __construct(
public TagName $name
) {}
) {
}
public static function div(): self
{
@@ -64,4 +65,4 @@ final readonly class HtmlTag
{
return $this->name->value;
}
}
}

View File

@@ -10,7 +10,8 @@ final readonly class StandardHtmlElement implements HtmlElement
public HtmlTag $tag,
public HtmlAttributes $attributes = new HtmlAttributes(),
public string $content = ''
) {}
) {
}
public static function create(TagName $tagName, ?HtmlAttributes $attributes = null, string $content = ''): self
{
@@ -48,4 +49,4 @@ final readonly class StandardHtmlElement implements HtmlElement
return "<{$tagName}{$attributesPart}>{$this->content}</{$tagName}>";
}
}
}

View File

@@ -23,7 +23,10 @@ enum TagName: string
case H5 = 'h5';
case H6 = 'h6';
case A = 'a';
case LINK = 'link';
case IMG = 'img';
case NAV = 'nav';
case HEADER = 'header';
case UL = 'ul';
case OL = 'ol';
case LI = 'li';
@@ -38,7 +41,7 @@ enum TagName: string
public function isSelfClosing(): bool
{
return match ($this) {
self::INPUT, self::IMG => true,
self::INPUT, self::IMG, self::LINK => true,
default => false
};
}
@@ -50,4 +53,4 @@ enum TagName: string
default => false
};
}
}
}

View File

@@ -0,0 +1,16 @@
<div class="card-wrapper" data-component-id="{component.id}">
<!-- Header Slot -->
<slot name="header">
<div class="card-header-default">
<h3>Default Card Title</h3>
</div>
</slot>
<!-- Body Slot (Required) -->
<slot name="body">
<!-- This will cause validation error if not provided -->
</slot>
<!-- Footer Slot (Optional) -->
<slot name="footer"></slot>
</div>

View File

@@ -0,0 +1,18 @@
<div class="container-wrapper" data-component-id="{component.id}">
<!-- Title Slot (Scoped) -->
<!-- Parent can access: {context.containerId}, {context.hasContent} -->
<slot name="title">
<h2>Container Title</h2>
</slot>
<!-- Default Slot (Unnamed) -->
<!-- All unnamed content goes here -->
<slot>
<div class="empty-container">
<p>No content provided</p>
</div>
</slot>
<!-- Actions Slot (Optional) -->
<slot name="actions"></slot>
</div>

View File

@@ -0,0 +1,26 @@
<div class="layout-container" data-component-id="{component.id}">
<!-- Header Slot (Optional) -->
<slot name="header"></slot>
<div class="layout-body">
<!-- Sidebar Slot (Scoped) -->
<!-- Parent can access: {context.sidebarWidth}, {context.isCollapsed} -->
<slot name="sidebar">
<aside class="sidebar-default">
<p>Default Sidebar</p>
</aside>
</slot>
<!-- Main Content Slot (Required) -->
<slot name="main">
<!-- This will cause validation error if not provided -->
</slot>
</div>
<!-- Footer Slot (Optional) -->
<slot name="footer">
<footer class="layout-footer-default">
<p>Default Footer</p>
</footer>
</slot>
</div>

View File

@@ -0,0 +1,22 @@
<div class="modal-overlay" data-modal-id="{component.id}" data-is-open="{state.isOpen}">
<div class="modal-dialog">
<!-- Title Slot -->
<slot name="title">
<div class="modal-title-default">
<h3>Modal Title</h3>
</div>
</slot>
<!-- Content Slot (Required, Scoped) -->
<!-- Parent can access: {context.modalId}, {context.isOpen}, {context.closeFunction} -->
<slot name="content">
<p>Modal content goes here</p>
</slot>
<!-- Actions Slot (Scoped) -->
<!-- Parent can access: {context.closeFunction}, {context.modalId} -->
<slot name="actions">
<button onclick="{context.closeFunction}">Close</button>
</slot>
</div>
</div>

View File

@@ -216,32 +216,14 @@
</head>
<body class="admin-page">
<div class="admin-layout">
<aside class="admin-sidebar">
<for var="section_data" in="navigation_menu">
<div class="nav-section">
<h3>{{ section_data.section }}</h3>
<ul class="nav-items">
<for var="item" in="section_data.items">
<li>
<a href="{{ item.url }}">{{ item.name }}</a>
</li>
</for>
</ul>
</div>
</for>
</aside>
<!-- Sidebar Navigation -->
<x-admin-sidebar currentPath="{current_path}" />
<main class="admin-content">
<div class="breadcrumbs">
<for var="crumb" in="breadcrumbs_data">
<if condition="{{ crumb.url }}">
<a href="{{ crumb.url }}">{{ crumb.name }}</a>
<else>
<span>{{ crumb.name }}</span>
</if>
<span> / </span>
</for>
</div>
<!-- Header with Search & User Menu -->
<x-admin-header pageTitle="{page_title}" />
<!-- Main Content Area -->
<main class="admin-content" id="main-content" role="main">
<div class="page-header">
<h1>{{ page_title }}</h1>

View File

@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveComponent Activity Feed Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
margin-bottom: 1rem;
color: #333;
}
.intro {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.intro h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #444;
}
.feature-list {
line-height: 1.8;
color: #666;
}
.feature-list li {
margin-bottom: 0.5rem;
}
.demo-section {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.demo-section h3 {
margin-bottom: 1.5rem;
color: #333;
font-size: 1.125rem;
}
.demo-controls {
background: #f9f9f9;
padding: 1.5rem;
border-radius: 6px;
margin-bottom: 2rem;
}
.demo-controls h4 {
margin-bottom: 1rem;
color: #333;
}
.control-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.btn-demo {
padding: 0.75rem 1.5rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.btn-demo:hover {
background: #1976D2;
}
.btn-demo.btn-success {
background: #4CAF50;
}
.btn-demo.btn-success:hover {
background: #388E3C;
}
.btn-demo.btn-warning {
background: #FF9800;
}
.btn-demo.btn-warning:hover {
background: #F57C00;
}
.btn-demo.btn-danger {
background: #f44336;
}
.btn-demo.btn-danger:hover {
background: #d32f2f;
}
.info-box {
background: #e3f2fd;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
border-left: 4px solid #2196F3;
}
.info-box strong {
color: #1976D2;
}
.activity-types {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.activity-type-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.875rem;
}
.activity-type-icon {
font-size: 1.25rem;
}
</style>
</head>
<body data-module="livecomponent">
<div class="container">
<h1>📊 LiveComponent Activity Feed Demo</h1>
<div class="intro">
<h2>Real-time Activity Feed mit Live-Updates</h2>
<ul class="feature-list">
<li> <strong>Activity Types:</strong> Comment, Like, Share, Follow, Mention, System</li>
<li> <strong>Filter System:</strong> Nach Activity-Type und Unread Status filtern</li>
<li> <strong>Mark as Read:</strong> Einzelne oder alle Aktivitäten als gelesen markieren</li>
<li> <strong>Time Grouping:</strong> Heute, Gestern, Diese Woche, Älter</li>
<li> <strong>Relative Timestamps:</strong> "vor 2 Minuten", "vor 1 Stunde"</li>
<li> <strong>Unread Badge:</strong> Anzahl ungelesener Aktivitäten</li>
<li> <strong>Pagination:</strong> Load More mit verbleibenden Aktivitäten</li>
<li> <strong>User Avatars:</strong> Avatar-Anzeige mit Activity-Type Icons</li>
<li> <strong>Remove Activities:</strong> Einzelne Aktivitäten entfernen</li>
<li> <strong>Activity Count:</strong> Zeige X von Y Aktivitäten</li>
</ul>
<div class="activity-types">
<div class="activity-type-badge">
<span class="activity-type-icon">💬</span>
<span>Kommentar</span>
</div>
<div class="activity-type-badge">
<span class="activity-type-icon">❤️</span>
<span>Like</span>
</div>
<div class="activity-type-badge">
<span class="activity-type-icon">🔄</span>
<span>Share</span>
</div>
<div class="activity-type-badge">
<span class="activity-type-icon">👤</span>
<span>Follow</span>
</div>
<div class="activity-type-badge">
<span class="activity-type-icon">@</span>
<span>Erwähnung</span>
</div>
<div class="activity-type-badge">
<span class="activity-type-icon"></span>
<span>System</span>
</div>
</div>
</div>
<div class="demo-controls">
<h4>Schnellaktionen</h4>
<div class="control-row">
<button class="btn-demo btn-success" onclick="addComment()">
💬 Kommentar hinzufügen
</button>
<button class="btn-demo btn-success" onclick="addLike()">
❤️ Like hinzufügen
</button>
<button class="btn-demo btn-success" onclick="addFollow()">
👤 Follow hinzufügen
</button>
<button class="btn-demo btn-warning" onclick="addMultipleActivities()">
🎯 Mehrere hinzufügen
</button>
</div>
<div class="control-row">
<button class="btn-demo" onclick="markAllRead()">
Alle als gelesen
</button>
<button class="btn-demo" onclick="filterUnread()">
🔍 Nur Ungelesene
</button>
<button class="btn-demo btn-danger" onclick="clearAllActivities()">
🗑️ Alle löschen
</button>
</div>
<div class="info-box">
<strong>Tipp:</strong> Verwende die Filter-Tabs um nach Activity-Type zu filtern.
Klicke auf das -Icon um einzelne Aktivitäten als gelesen zu markieren.
Die Zeitgruppierung zeigt "Heute", "Gestern", "Diese Woche" und "Älter".
</div>
</div>
<!-- Activity Feed Component -->
<div class="demo-section">
<h3>Activity Feed</h3>
{{activity_feed}}
</div>
</div>
<script>
const demoUsers = [
{ id: '1', name: 'Max Mustermann' },
{ id: '2', name: 'Anna Schmidt' },
{ id: '3', name: 'Tom Weber' },
{ id: '4', name: 'Lisa Müller' },
{ id: '5', name: 'Jonas Fischer' },
{ id: '6', name: 'Sarah Klein' }
];
const demoContents = {
comment: [
'hat deinen Beitrag kommentiert: "Sehr interessant!"',
'hat auf deinen Kommentar geantwortet',
'hat einen neuen Kommentar geschrieben',
'hat deinen Beitrag kommentiert: "Tolle Arbeit!"'
],
like: [
'gefällt dein Beitrag',
'hat deinen Kommentar geliket',
'gefällt deine Story',
'hat dein Foto geliket'
],
share: [
'hat deinen Beitrag geteilt',
'hat deine Story geteilt',
'hat deinen Artikel geteilt',
'hat dein Video geteilt'
],
follow: [
'folgt dir jetzt',
'hat begonnen dir zu folgen',
'ist jetzt dein Follower',
'folgt deinem Profil'
],
mention: [
'hat dich in einem Beitrag erwähnt',
'hat dich in einem Kommentar erwähnt',
'hat dich getaggt',
'hat dich in einer Story erwähnt'
],
system: [
'Dein Beitrag wurde veröffentlicht',
'Dein Profil wurde aktualisiert',
'Neue Sicherheitseinstellungen verfügbar',
'Backup erfolgreich erstellt'
]
};
// Helper function to trigger activity actions
function triggerActivityAction(action, params = {}) {
const component = document.querySelector('[data-component-id^="activity-feed:"]');
if (!component) {
console.error('Activity feed component not found');
return;
}
const button = document.createElement('button');
button.setAttribute('data-live-action', action);
Object.entries(params).forEach(([key, value]) => {
button.setAttribute(`data-param-${key}`, typeof value === 'object' ? JSON.stringify(value) : value);
});
button.style.display = 'none';
component.appendChild(button);
button.click();
component.removeChild(button);
}
function getRandomUser() {
return demoUsers[Math.floor(Math.random() * demoUsers.length)];
}
function getRandomContent(type) {
const contents = demoContents[type];
return contents[Math.floor(Math.random() * contents.length)];
}
// Add specific activity types
function addComment() {
const user = getRandomUser();
triggerActivityAction('addActivity', {
'activity-type': 'comment',
'user-id': user.id,
'user-name': user.name,
'content': getRandomContent('comment'),
'avatar-url': null,
'metadata': JSON.stringify({})
});
}
function addLike() {
const user = getRandomUser();
triggerActivityAction('addActivity', {
'activity-type': 'like',
'user-id': user.id,
'user-name': user.name,
'content': getRandomContent('like'),
'avatar-url': null,
'metadata': JSON.stringify({})
});
}
function addFollow() {
const user = getRandomUser();
triggerActivityAction('addActivity', {
'activity-type': 'follow',
'user-id': user.id,
'user-name': user.name,
'content': getRandomContent('follow'),
'avatar-url': null,
'metadata': JSON.stringify({})
});
}
function addMultipleActivities() {
const types = ['comment', 'like', 'share', 'follow', 'mention', 'system'];
types.forEach((type, index) => {
setTimeout(() => {
const user = getRandomUser();
triggerActivityAction('addActivity', {
'activity-type': type,
'user-id': user.id,
'user-name': user.name,
'content': getRandomContent(type),
'avatar-url': null,
'metadata': JSON.stringify({})
});
}, index * 300);
});
}
function markAllRead() {
triggerActivityAction('markAllAsRead', {});
}
function filterUnread() {
triggerActivityAction('setFilter', { filter: 'unread' });
}
function clearAllActivities() {
if (confirm('Möchten Sie wirklich alle Aktivitäten löschen?')) {
triggerActivityAction('clearAll', {});
}
}
// Initialize with some demo activities
window.addEventListener('DOMContentLoaded', function() {
// Add initial activities after a short delay
setTimeout(() => {
addMultipleActivities();
}, 500);
// Add more activities every 10 seconds
setInterval(() => {
const types = ['comment', 'like', 'share', 'follow', 'mention'];
const type = types[Math.floor(Math.random() * types.length)];
const user = getRandomUser();
triggerActivityAction('addActivity', {
'activity-type': type,
'user-id': user.id,
'user-name': user.name,
'content': getRandomContent(type),
'avatar-url': null,
'metadata': JSON.stringify({})
});
}, 10000);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,455 @@
<div class="activity-feed">
<!-- Activity Feed Header -->
<div class="activity-header">
<h3 class="activity-title">
{{active_filter_label}}
<span class="unread-badge" if="has_unread">{{unread_badge}}</span>
</h3>
<div class="activity-actions">
<button
class="mark-all-read-btn"
if="show_mark_all_read"
data-live-action="markAllAsRead"
title="Alle als gelesen markieren"
>
Alle gelesen
</button>
</div>
</div>
<!-- Filter Tabs -->
<div class="activity-filters" if="show_filters">
<for items="filter_options" as="option">
<button
class="filter-tab {{option.is_active ? 'active' : ''}}"
data-live-action="setFilter"
data-param-filter="{{option.value}}"
>
{{option.label}}
<span class="filter-count" if="option.count > 0">({{option.count}})</span>
</button>
</for>
</div>
<!-- Empty State -->
<div class="activity-empty" if="is_empty">
<div class="empty-icon">📭</div>
<h3 class="empty-title">Keine Aktivitäten</h3>
<p class="empty-text">Es gibt derzeit keine Aktivitäten in diesem Feed.</p>
</div>
<!-- Activity List -->
<div class="activity-list" if="has_activities">
<for items="grouped_activities" as="group">
<div class="activity-group">
<div class="group-date-label" if="group.date_label">
{{group.date_label}}
</div>
<div class="group-activities">
<for items="group.activities" as="activity">
<div class="activity-item {{activity.read_class}} {{activity.activity_class}}">
<!-- Activity Icon & Avatar -->
<div class="activity-visual">
<div class="activity-avatar" if="activity.show_avatar">
<img
src="{{activity.avatar_url}}"
alt="{{activity.user_name}}"
class="avatar-img"
/>
</div>
<div class="activity-icon {{activity.icon_class}}">
{{activity.icon}}
</div>
</div>
<!-- Activity Content -->
<div class="activity-content">
<div class="activity-text">
<strong class="activity-user">{{activity.user_name}}</strong>
{{activity.content}}
</div>
<div class="activity-meta" if="activity.show_timestamp">
<span class="activity-time" title="{{activity.absolute_time}}">
{{activity.relative_time}}
</span>
<span class="activity-type-label"> {{activity.type_label}}</span>
</div>
</div>
<!-- Activity Actions -->
<div class="activity-actions-menu">
<button
class="activity-mark-read"
if="!activity.is_read"
data-live-action="markAsRead"
data-param-activity-id="{{activity.id}}"
title="Als gelesen markieren"
>
</button>
<button
class="activity-remove"
data-live-action="removeActivity"
data-param-activity-id="{{activity.id}}"
title="Entfernen"
>
×
</button>
</div>
</div>
</for>
</div>
</div>
</for>
</div>
<!-- Load More Button -->
<div class="activity-footer" if="has_activities">
<button
class="load-more-btn"
if="has_more"
data-live-action="loadMore"
>
{{load_more_label}}
</button>
<div class="activity-count-info">
Zeige {{showing_count}} von {{total_count}} Aktivitäten
</div>
</div>
</div>
<style>
.activity-feed {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
/* Header */
.activity-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 2px solid #e0e0e0;
background: #f9f9f9;
}
.activity-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 0.75rem;
}
.unread-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: #f44336;
color: white;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
}
.mark-all-read-btn {
padding: 0.5rem 1rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
font-size: 0.875rem;
}
.mark-all-read-btn:hover {
background: #1976D2;
}
/* Filters */
.activity-filters {
display: flex;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e0e0e0;
overflow-x: auto;
background: white;
}
.filter-tab {
padding: 0.5rem 1rem;
background: #f5f5f5;
color: #666;
border: 1px solid #e0e0e0;
border-radius: 20px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
font-size: 0.875rem;
}
.filter-tab:hover {
background: #eeeeee;
border-color: #bdbdbd;
}
.filter-tab.active {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.filter-count {
margin-left: 0.25rem;
opacity: 0.8;
}
/* Empty State */
.activity-empty {
text-align: center;
padding: 4rem 2rem;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
color: #333;
}
.empty-text {
color: #666;
margin: 0;
}
/* Activity List */
.activity-list {
max-height: 600px;
overflow-y: auto;
}
.activity-group {
margin-bottom: 0.5rem;
}
.group-date-label {
padding: 0.75rem 1.5rem;
background: #f5f5f5;
font-weight: 600;
font-size: 0.875rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
z-index: 1;
}
.group-activities {
background: white;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
position: relative;
}
.activity-item:hover {
background: #fafafa;
}
.activity-item.unread {
background: #e3f2fd;
}
.activity-item.unread:hover {
background: #d1e7fd;
}
/* Activity Visual */
.activity-visual {
position: relative;
flex-shrink: 0;
}
.activity-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.activity-icon {
position: absolute;
bottom: -4px;
right: -4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.activity-icon-comment { background: #2196F3; }
.activity-icon-like { background: #f44336; }
.activity-icon-share { background: #4CAF50; }
.activity-icon-follow { background: #9C27B0; }
.activity-icon-mention { background: #FF9800; }
.activity-icon-system { background: #607D8B; }
/* Activity Content */
.activity-content {
flex: 1;
min-width: 0;
}
.activity-text {
color: #333;
line-height: 1.5;
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.activity-user {
color: #1976D2;
font-weight: 600;
}
.activity-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: #888;
}
.activity-time {
cursor: help;
}
/* Activity Actions Menu */
.activity-actions-menu {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
}
.activity-item:hover .activity-actions-menu {
opacity: 1;
}
.activity-mark-read,
.activity-remove {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 1rem;
color: #666;
}
.activity-mark-read:hover {
background: #4CAF50;
color: white;
border-color: #4CAF50;
}
.activity-remove:hover {
background: #f44336;
color: white;
border-color: #f44336;
}
/* Footer */
.activity-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e0e0e0;
background: #f9f9f9;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.load-more-btn {
padding: 0.75rem 2rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.load-more-btn:hover {
background: #1976D2;
}
.activity-count-info {
font-size: 0.875rem;
color: #666;
text-align: center;
}
/* Scrollbar Styling */
.activity-list::-webkit-scrollbar {
width: 8px;
}
.activity-list::-webkit-scrollbar-track {
background: #f0f0f0;
}
.activity-list::-webkit-scrollbar-thumb {
background: #bdbdbd;
border-radius: 4px;
}
.activity-list::-webkit-scrollbar-thumb:hover {
background: #9e9e9e;
}
</style>

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveComponent Autocomplete Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
background: #f5f5f5;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
margin-bottom: 1rem;
color: #333;
}
.intro {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.intro h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #444;
}
.feature-list {
line-height: 1.8;
color: #666;
}
.feature-list li {
margin-bottom: 0.5rem;
}
.demo-section {
margin-bottom: 2rem;
}
</style>
</head>
<body data-module="livecomponent">
<div class="container">
<h1>🎯 LiveComponent Autocomplete Demo</h1>
<div class="intro">
<h2>Intelligent Autocomplete with Context Switching</h2>
<ul class="feature-list">
<li> <strong>Context-Aware:</strong> Different suggestions for different contexts</li>
<li> <strong>Smart Sorting:</strong> Exact matches first, then starts-with, then contains</li>
<li> <strong>Debouncing:</strong> 200ms delay for optimal performance</li>
<li> <strong>Recent Searches:</strong> Track and reuse recent selections</li>
<li> <strong>Visual Feedback:</strong> Icons and categories for each suggestion</li>
<li> <strong>Minimum Characters:</strong> Shows hint when less than 2 characters</li>
</ul>
</div>
<div class="demo-section">
{{autocomplete}}
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,346 @@
<div
class="autocomplete-component"
data-live-component="{{componentId}}"
data-state='{{stateJson}}'
>
<div class="autocomplete-header">
<h3 class="autocomplete-title">Intelligent Autocomplete</h3>
<div class="context-selector">
<for items="contextOptions" as="option">
<button
class="context-btn {{option.isActive ? 'active' : ''}}"
data-live-action="changeContext"
data-param-context="{{option.value}}"
>
{{option.icon}} {{option.label}}
</button>
</for>
</div>
</div>
<div class="autocomplete-input-wrapper">
<div class="input-icon">🔍</div>
<input
type="text"
name="query"
class="autocomplete-input"
placeholder="{{inputPlaceholder}}"
value="{{query}}"
data-live-action="getSuggestions"
data-live-debounce="200"
autocomplete="off"
/>
</div>
<if condition="hasSuggestions">
<div class="suggestions-dropdown {{dropdownClass}}">
<div class="suggestions-header">
<span class="suggestions-count">{{suggestionCount}} Vorschläge</span>
</div>
<div class="suggestions-list">
<for items="suggestions" as="suggestion">
<div
class="suggestion-item {{suggestion.displayClass}}"
data-live-action="selectSuggestion"
data-param-suggestion="{{suggestion.text}}"
data-param-context="{{context}}"
>
<div class="suggestion-icon">{{suggestion.icon}}</div>
<div class="suggestion-content">
<div class="suggestion-text">{{suggestion.highlightedText}}</div>
<div class="suggestion-category">{{suggestion.category}}</div>
</div>
</div>
</for>
</div>
</div>
</if>
<if condition="!hasQuery">
<div class="autocomplete-hint">
<div class="hint-icon">💡</div>
<div class="hint-text">{{emptyStateMessage}}</div>
</div>
</if>
<if condition="selected_value">
<div class="selected-value-display">
<div class="selected-label">Ausgewählter Wert:</div>
<div class="selected-value">{{selected_value}}</div>
</div>
</if>
<if condition="hasRecentSearches">
<div class="recent-searches">
<div class="recent-header">
<span class="recent-title">Letzte Suchen ({{recentSearchCount}})</span>
<if condition="showClearRecent">
<button
class="clear-recent-btn"
data-live-action="clearRecent"
>
Alle löschen
</button>
</if>
</div>
<div class="recent-list">
<for items="recent_searches" as="search">
<div
class="recent-item"
data-live-action="selectSuggestion"
data-param-suggestion="{{search.text}}"
data-param-context="{{search.context}}"
>
<div class="recent-icon">🕐</div>
<div class="recent-text">{{search.text}}</div>
<div class="recent-context">{{search.context}}</div>
</div>
</for>
</div>
</div>
</if>
</div>
<style>
.autocomplete-component {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.autocomplete-header {
margin-bottom: 1.5rem;
}
.autocomplete-title {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #333;
}
.context-selector {
display: flex;
gap: 0.5rem;
}
.context-btn {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.context-btn:hover {
background: #f5f5f5;
}
.context-btn.active {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.autocomplete-input-wrapper {
position: relative;
margin-bottom: 0.5rem;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 1.25rem;
pointer-events: none;
}
.autocomplete-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
font-size: 1rem;
border: 2px solid #e0e0e0;
border-radius: 6px;
transition: border-color 0.2s;
}
.autocomplete-input:focus {
outline: none;
border-color: #2196F3;
}
.suggestions-dropdown {
background: white;
border: 2px solid #2196F3;
border-radius: 6px;
margin-bottom: 1rem;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.suggestions-header {
padding: 0.5rem 1rem;
background: #f9f9f9;
border-bottom: 1px solid #e0e0e0;
}
.suggestions-count {
font-size: 0.875rem;
color: #666;
font-weight: 600;
}
.suggestions-list {
max-height: 300px;
overflow-y: auto;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid #f0f0f0;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover {
background: #f5f5f5;
}
.suggestion-icon {
font-size: 1.5rem;
width: 2rem;
text-align: center;
}
.suggestion-content {
flex: 1;
}
.suggestion-text {
font-weight: 600;
color: #333;
margin-bottom: 0.125rem;
}
.suggestion-category {
font-size: 0.75rem;
color: #999;
}
.autocomplete-hint {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 6px;
margin-bottom: 1rem;
}
.hint-icon {
font-size: 1.5rem;
}
.hint-text {
color: #666;
font-size: 0.875rem;
}
.selected-value-display {
background: #e3f2fd;
padding: 1rem;
border-radius: 6px;
border-left: 4px solid #2196F3;
margin-bottom: 1rem;
}
.selected-label {
font-size: 0.875rem;
color: #666;
margin-bottom: 0.25rem;
}
.selected-value {
font-weight: 600;
color: #1976D2;
font-size: 1.125rem;
}
.recent-searches {
margin-top: 1.5rem;
}
.recent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.recent-title {
font-weight: 600;
color: #555;
}
.clear-recent-btn {
background: none;
border: none;
color: #f44336;
cursor: pointer;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background 0.2s;
}
.clear-recent-btn:hover {
background: #ffebee;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recent-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f9f9f9;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.recent-item:hover {
background: #f0f0f0;
}
.recent-icon {
font-size: 1.25rem;
}
.recent-text {
flex: 1;
font-weight: 500;
color: #333;
}
.recent-context {
font-size: 0.75rem;
color: #999;
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveComponent Caching Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
margin-bottom: 2rem;
color: #333;
}
.demo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h2 {
margin-bottom: 1rem;
color: #444;
font-size: 1.25rem;
}
.single-column {
grid-column: 1 / -1;
}
.performance-meter {
background: #f9f9f9;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
}
.performance-bar {
height: 20px;
background: linear-gradient(to right, #4CAF50, #FF9800, #f44336);
border-radius: 10px;
position: relative;
margin: 1rem 0;
}
.performance-indicator {
position: absolute;
top: -5px;
width: 2px;
height: 30px;
background: #333;
transition: left 0.3s ease;
}
.feature-list {
line-height: 1.8;
color: #666;
}
.feature-list li {
margin-bottom: 0.5rem;
}
.cache-info {
background: #e3f2fd;
padding: 1rem;
border-radius: 4px;
border-left: 4px solid #2196F3;
}
.cache-info p {
margin: 0.5rem 0;
}
</style>
</head>
<body data-module="livecomponent">
<div class="container">
<h1> LiveComponent Caching Demo</h1>
<div class="demo-grid">
<div class="card">
<h2>Stats Component (Cached)</h2>
{{stats1}}
<div class="cache-info" style="margin-top: 1rem;">
<p><strong>Cache TTL:</strong> 30 seconds</p>
<p><strong>Tag:</strong> stats, dashboard-components</p>
<p><strong>Performance:</strong> ~500ms without cache, ~1ms with cache</p>
</div>
</div>
<div class="card">
<h2>Stats Component (Uncached)</h2>
{{stats2}}
<div class="cache-info" style="margin-top: 1rem; background: #ffebee; border-color: #f44336;">
<p><strong>Cache:</strong> Disabled</p>
<p><strong>Performance:</strong> Always ~500ms (expensive computation)</p>
</div>
</div>
<div class="card single-column">
<h2>How Component Caching Works</h2>
<ul class="feature-list">
<li> <strong>Automatic Caching:</strong> Components implementing `Cacheable` are cached automatically</li>
<li> <strong>TTL Support:</strong> Configure cache duration per component (default: 30 seconds)</li>
<li> <strong>Cache Keys:</strong> Unique keys based on component ID and state</li>
<li> <strong>Cache Tags:</strong> Group components for batch invalidation</li>
<li> <strong>Conditional Caching:</strong> `shouldCache()` controls caching per request</li>
<li> <strong>Manual Invalidation:</strong> Refresh action invalidates cache on demand</li>
<li> <strong>Performance Boost:</strong> 500x faster rendering with cache</li>
</ul>
</div>
<div class="card single-column">
<h2>Implementation Example</h2>
<pre style="background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; font-size: 0.875rem;"><code>#[LiveComponent('stats')]
final readonly class StatsComponent implements Cacheable
{
public function getCacheKey(): string
{
return $this->id . ':' . ($this->cacheEnabled ? 'enabled' : 'disabled');
}
public function getCacheTTL(): Duration
{
return Duration::fromSeconds(30);
}
public function shouldCache(): bool
{
return $this->initialData['cache_enabled'] ?? true;
}
public function getCacheTags(): array
{
return ['stats', 'dashboard-components'];
}
}</code></pre>
</div>
<div class="card single-column">
<h2>Cache Invalidation Strategies</h2>
<ul class="feature-list">
<li><strong>Time-based:</strong> Cache expires after TTL (30 seconds)</li>
<li><strong>Manual:</strong> Refresh button invalidates cache immediately</li>
<li><strong>Tag-based:</strong> Invalidate all components with tag "stats"</li>
<li><strong>Action-based:</strong> Cache invalidated after state-changing actions</li>
</ul>
</div>
</div>
<div class="card" style="background: #fff3cd; border: 1px solid #ffc107;">
<h2>💡 Performance Tip</h2>
<p style="margin-bottom: 1rem;">Notice the "Render Count" difference:</p>
<ul class="feature-list">
<li>Cached component: Render count stays at 1 (served from cache)</li>
<li>Uncached component: Render count increases on every refresh</li>
<li>Click "Refresh" to see cache invalidation in action</li>
<li>Toggle cache on/off to compare performance</li>
</ul>
</div>
</div>
<script>
// Performance monitoring (Demo-specific)
document.addEventListener('livecomponent:stats:refreshed', (e) => {
console.log('Stats refreshed:', e.detail);
});
const startTime = performance.now();
window.addEventListener('load', () => {
const loadTime = performance.now() - startTime;
console.log(`Page load time: ${loadTime.toFixed(2)}ms`);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveComponent Chart Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
background: #f5f5f5;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
margin-bottom: 1rem;
color: #333;
}
.intro {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.intro h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #444;
}
.feature-list {
line-height: 1.8;
color: #666;
}
.feature-list li {
margin-bottom: 0.5rem;
}
.demo-section {
margin-bottom: 2rem;
}
</style>
</head>
<body data-module="livecomponent">
<div class="container">
<h1>📊 LiveComponent Chart Demo</h1>
<div class="intro">
<h2>Real-Time Data Visualization Dashboard</h2>
<ul class="feature-list">
<li> <strong>Multiple Chart Types:</strong> Line, Bar, Area, and Pie charts</li>
<li> <strong>Real-Time Updates:</strong> Auto-refresh with configurable intervals</li>
<li> <strong>Time Range Selection:</strong> View data for 1h, 24h, 7d, or 30d</li>
<li> <strong>Interactive Controls:</strong> Pause/resume auto-refresh, manual refresh</li>
<li> <strong>CSS-Based Charts:</strong> No external libraries - pure CSS visualization</li>
<li> <strong>Performance Tracking:</strong> Monitor update times and data point counts</li>
<li> <strong>Responsive Design:</strong> Adapts to different screen sizes</li>
<li> <strong>Live Statistics:</strong> Summary cards with key metrics</li>
</ul>
</div>
<div class="demo-section">
{{chart}}
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,590 @@
<div
class="chart-component"
data-live-component="{{componentId}}"
data-state='{{stateJson}}'
>
<!-- Header Controls -->
<div class="chart-header">
<div class="header-left">
<h3 class="component-title">{{chartIcon}} Real-Time Analytics Dashboard</h3>
<div class="chart-stats">
<span class="stat">
<strong>{{data_points_count}}</strong> Datenpunkte
</span>
<span class="stat" if="showPerformanceMetric">
Letztes Update: <strong>{{execution_time_ms}}ms</strong>
</span>
<span class="stat">
<span class="status-indicator {{autoRefreshIndicatorClass}}"></span>
{{autoRefreshStatusText}}
</span>
</div>
</div>
<div class="header-right">
<button
class="btn {{autoRefreshButtonClass}}"
data-live-action="toggleAutoRefresh"
>
{{autoRefreshButtonText}}
</button>
<button
class="btn btn-primary"
data-live-action="refreshData"
>
🔄 Aktualisieren
</button>
</div>
</div>
<!-- Chart Type Selector -->
<div class="chart-controls">
<div class="control-group">
<label>Diagrammtyp:</label>
<div class="button-group">
<for items="chartTypeOptions" as="option">
<button
class="btn-type {{option.activeClass}}"
data-live-action="changeChartType"
data-param-chart-type="{{option.value}}"
>
{{option.icon}} {{option.label}}
</button>
</for>
</div>
</div>
<div class="control-group">
<label>Zeitbereich:</label>
<select
class="range-select"
data-live-action="changeDataRange"
>
<for items="dataRangeOptions" as="option">
<option value="{{option.value}}" {{option.selectedAttribute}}>
{{option.label}}
</option>
</for>
</select>
</div>
</div>
<!-- Chart Display Area -->
<div class="chart-display">
<div class="pie-chart-container" if="isPieChart">
<div class="pie-chart">
<!-- Simple CSS Pie Chart Representation -->
<div class="pie-legend">
<for items="pieSegments" as="segment">
<div class="legend-item">
<span class="legend-color" style="background-color: {{segment.color}}"></span>
<span class="legend-label">{{segment.label}}</span>
<span class="legend-value">{{segment.value}}%</span>
</div>
</for>
</div>
<div class="pie-visual">
<for items="pieSegments" as="segment">
<div
class="pie-segment"
style="background-color: {{segment.color}}; width: {{segment.size}}; height: {{segment.size}};"
>
{{segment.value}}%
</div>
</for>
</div>
</div>
</div>
<div class="line-chart-container" if="!isPieChart">
<!-- Chart Canvas (CSS-based visualization) -->
<div class="chart-canvas">
<!-- Y-axis labels -->
<div class="y-axis">
<div class="y-label">100</div>
<div class="y-label">75</div>
<div class="y-label">50</div>
<div class="y-label">25</div>
<div class="y-label">0</div>
</div>
<!-- Data visualization -->
<div class="chart-data">
<div class="grid-lines">
<div class="grid-line"></div>
<div class="grid-line"></div>
<div class="grid-line"></div>
<div class="grid-line"></div>
</div>
<div class="data-bars">
<for items="barItems" as="bar">
<div
class="{{bar.barClass}}"
style="{{bar.barStyle}}"
title="{{bar.title}}"
data-label-index="{{bar.labelIndex}}"
></div>
</for>
</div>
</div>
</div>
<!-- X-axis labels -->
<div class="x-axis">
<for items="barLabels" as="label">
<div class="x-label">{{label.text}}</div>
</for>
</div>
<!-- Legend -->
<div class="chart-legend">
<for items="chartDatasets" as="dataset">
<div class="legend-item">
<span class="legend-color" style="background-color: {{dataset.legendColor}}"></span>
<span class="legend-label">{{dataset.label}}</span>
</div>
</for>
</div>
</div>
</div>
<!-- Statistics Summary -->
<div class="chart-summary">
<div class="summary-card">
<div class="summary-label">Datenpunkte gesamt</div>
<div class="summary-value">{{data_points_count}}</div>
</div>
<div class="summary-card">
<div class="summary-label">Letztes Update</div>
<div class="summary-value">{{lastUpdateFormatted}}</div>
</div>
<div class="summary-card">
<div class="summary-label">Update-Intervall</div>
<div class="summary-value">{{refreshIntervalFormatted}}</div>
</div>
<div class="summary-card">
<div class="summary-label">Diagrammtyp</div>
<div class="summary-value">{{chartTypeLabel}}</div>
</div>
</div>
</div>
<style>
.chart-component {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e0e0e0;
gap: 1rem;
}
.header-left {
flex: 1;
}
.component-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #333;
}
.chart-stats {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
.stat {
font-size: 0.875rem;
color: #666;
display: flex;
align-items: center;
gap: 0.5rem;
}
.stat strong {
color: #2196F3;
font-weight: 600;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-indicator.active {
background: #4CAF50;
animation: pulse 2s infinite;
}
.status-indicator.paused {
background: #f44336;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.header-right {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
font-size: 0.875rem;
}
.btn:hover {
background: #f5f5f5;
}
.btn-primary {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.btn-primary:hover {
background: #1976D2;
}
.btn-success {
background: #4CAF50;
color: white;
border-color: #4CAF50;
}
.btn-success:hover {
background: #388E3C;
}
.btn-warning {
background: #FFC107;
color: #333;
border-color: #FFC107;
}
.btn-warning:hover {
background: #FFA000;
}
/* Chart Controls */
.chart-controls {
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 6px;
flex-wrap: wrap;
}
.control-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.control-group label {
font-weight: 600;
color: #555;
font-size: 0.875rem;
}
.button-group {
display: flex;
gap: 0.5rem;
}
.btn-type {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.btn-type:hover {
background: #f5f5f5;
}
.btn-type.active {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.range-select {
padding: 0.5rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
}
/* Chart Display */
.chart-display {
margin-bottom: 1.5rem;
min-height: 400px;
}
/* Line/Bar/Area Chart */
.line-chart-container {
position: relative;
}
.chart-canvas {
display: flex;
height: 300px;
margin-bottom: 1rem;
}
.y-axis {
display: flex;
flex-direction: column;
justify-content: space-between;
padding-right: 0.5rem;
width: 40px;
}
.y-label {
font-size: 0.75rem;
color: #999;
text-align: right;
}
.chart-data {
flex: 1;
position: relative;
border-left: 2px solid #ddd;
border-bottom: 2px solid #ddd;
}
.grid-lines {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.grid-line {
height: 1px;
background: #f0f0f0;
}
.data-bars {
position: absolute;
bottom: 0;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: flex-end;
gap: 2px;
padding: 0 0.25rem;
}
.bar-group {
flex: 1;
display: flex;
align-items: flex-end;
gap: 1px;
}
.bar {
flex: 1;
min-width: 8px;
transition: all 0.3s;
border-radius: 2px 2px 0 0;
}
.bar:hover {
opacity: 0.8;
}
.area-bar {
opacity: 0.7;
}
.x-axis {
display: flex;
justify-content: space-between;
margin-left: 40px;
padding: 0 0.25rem;
}
.x-label {
font-size: 0.75rem;
color: #999;
text-align: center;
flex: 1;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
.legend-label {
font-size: 0.875rem;
color: #666;
font-weight: 500;
}
.legend-value {
font-weight: 600;
color: #333;
}
/* Pie Chart */
.pie-chart-container {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.pie-chart {
display: flex;
gap: 3rem;
align-items: center;
}
.pie-visual {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
padding: 2rem;
}
.pie-segment {
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: white;
min-width: 80px;
min-height: 80px;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.pie-legend {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Summary Cards */
.chart-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
}
.summary-card {
padding: 1rem;
background: #f9f9f9;
border-radius: 6px;
text-align: center;
}
.summary-label {
font-size: 0.75rem;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.summary-value {
font-size: 1.5rem;
font-weight: 600;
color: #333;
}
/* Responsive */
@media (max-width: 768px) {
.chart-header {
flex-direction: column;
align-items: flex-start;
}
.chart-controls {
flex-direction: column;
gap: 1rem;
}
.button-group {
flex-wrap: wrap;
}
.chart-canvas {
height: 200px;
}
.pie-chart {
flex-direction: column;
}
}
</style>
<script if="auto_refresh">
// Auto-refresh functionality (JavaScript will handle this)
setTimeout(() => {
const refreshButton = document.querySelector('[data-live-action="refreshData"]');
if (refreshButton) {
refreshButton.click();
}
}, {{refresh_interval}});
</script>

View File

@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveComponent Comment Thread Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
margin-bottom: 1rem;
color: #333;
}
.intro {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.intro h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #444;
}
.feature-list {
line-height: 1.8;
color: #666;
}
.feature-list li {
margin-bottom: 0.5rem;
}
.demo-section {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.demo-section h3 {
margin-bottom: 1.5rem;
color: #333;
font-size: 1.125rem;
}
.demo-controls {
background: #f9f9f9;
padding: 1.5rem;
border-radius: 6px;
margin-top: 2rem;
}
.demo-controls h4 {
margin-bottom: 1rem;
color: #333;
}
.control-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.btn-demo {
padding: 0.75rem 1.5rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.btn-demo:hover {
background: #1976D2;
}
.btn-demo.btn-success {
background: #4CAF50;
}
.btn-demo.btn-success:hover {
background: #388E3C;
}
.btn-demo.btn-danger {
background: #f44336;
}
.btn-demo.btn-danger:hover {
background: #d32f2f;
}
.btn-demo.btn-secondary {
background: #9E9E9E;
}
.btn-demo.btn-secondary:hover {
background: #757575;
}
.info-box {
background: #e3f2fd;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
border-left: 4px solid #2196F3;
}
.info-box strong {
color: #1976D2;
}
</style>
</head>
<body data-module="livecomponent">
<div class="container">
<h1>💬 LiveComponent Comment Thread Demo</h1>
<div class="intro">
<h2>Interaktive Kommentar-Threads mit Reaktionen</h2>
<ul class="feature-list">
<li> <strong>Nested Comments:</strong> Verschachtelte Antworten bis zu 3 Ebenen tief</li>
<li> <strong>Reactions:</strong> Emoji-Reaktionen auf Kommentare</li>
<li> <strong>Real-time Updates:</strong> Live-Aktualisierung ohne Page Reload</li>
<li> <strong>Sorting:</strong> Sortierung nach Neueste, Älteste, Meist geliked</li>
<li> <strong>Editing & Deletion:</strong> Kommentare bearbeiten und löschen</li>
<li> <strong>User Mentions:</strong> Support für @mentions (kommend)</li>
<li> <strong>Statistics:</strong> Gesamt-Kommentare und Reaktionen</li>
<li> <strong>Responsive Design:</strong> Optimiert für Desktop und Mobile</li>
</ul>
</div>
<div class="demo-section">
<h3>Comment Thread Component</h3>
{{comment_thread}}
<div class="demo-controls">
<h4>Schnellaktionen</h4>
<div class="control-row">
<button class="btn-demo btn-success" onclick="addRandomComment()">
💬 Zufälligen Kommentar hinzufügen
</button>
<button class="btn-demo" onclick="addThreadDiscussion()">
🧵 Diskussion starten (5 Kommentare)
</button>
<button class="btn-demo btn-secondary" onclick="addReactionsToAll()">
👍 Reaktionen zu allen hinzufügen
</button>
</div>
<div class="control-row">
<button class="btn-demo" onclick="changeSortOrder('newest')">
📅 Neueste zuerst
</button>
<button class="btn-demo" onclick="changeSortOrder('oldest')">
🕐 Älteste zuerst
</button>
<button class="btn-demo" onclick="changeSortOrder('most_liked')">
❤️ Meist geliked
</button>
</div>
<div class="info-box">
<strong>Tipp:</strong> Klicke auf "Antworten" um verschachtelte Kommentare zu erstellen.
Verwende die 👍 Buttons um Reaktionen hinzuzufügen. Teste die verschiedenen Sortierungen!
</div>
</div>
</div>
</div>
<script>
// Helper function to trigger component actions
function triggerCommentAction(action, params = {}) {
const component = document.querySelector('[data-component-id^="comment-thread:"]');
if (!component) {
console.error('Comment thread component not found');
return;
}
const button = document.createElement('button');
button.setAttribute('data-live-action', action);
Object.entries(params).forEach(([key, value]) => {
button.setAttribute(`data-param-${key}`, typeof value === 'object' ? JSON.stringify(value) : value);
});
button.style.display = 'none';
component.appendChild(button);
button.click();
component.removeChild(button);
}
// Add Random Comment
function addRandomComment() {
const authors = [
'Max Mustermann',
'Anna Schmidt',
'Tom Weber',
'Lisa Müller',
'Jan Fischer',
'Sarah Wagner'
];
const comments = [
'Das ist ein wirklich interessanter Ansatz! 👍',
'Ich stimme dem vollkommen zu. Gute Arbeit!',
'Interessante Perspektive, aber ich sehe das etwas anders...',
'Genau das habe ich gesucht, vielen Dank! 🎉',
'Könnte man das noch weiter ausführen?',
'Super Erklärung, sehr hilfreich!',
'Ich habe eine Frage dazu: Wie funktioniert das genau?',
'Danke für den Beitrag! Hat mir sehr geholfen.',
'Das sollte man definitiv berücksichtigen.',
'Exzellenter Punkt! 💯'
];
const randomAuthor = authors[Math.floor(Math.random() * authors.length)];
const randomComment = comments[Math.floor(Math.random() * comments.length)];
triggerCommentAction('addComment', {
'content': randomComment,
'author-id': 'user_' + Date.now(),
'author-name': randomAuthor
});
}
// Add Thread Discussion (5 comments with replies)
function addThreadDiscussion() {
const discussion = [
{
author: 'Thomas Klein',
content: 'Hat jemand Erfahrungen mit diesem Ansatz in Production? 🤔',
delay: 0
},
{
author: 'Maria Berg',
content: 'Ja, wir nutzen das seit 6 Monaten. Funktioniert super!',
delay: 500,
parentIndex: 0
},
{
author: 'Peter Groß',
content: 'Welche Probleme hattet ihr anfangs?',
delay: 1000,
parentIndex: 1
},
{
author: 'Maria Berg',
content: 'Hauptsächlich Performance-Tuning. Nach Optimierung aber top! ⚡',
delay: 1500,
parentIndex: 2
},
{
author: 'Thomas Klein',
content: 'Danke für die Insights! Das hilft sehr. 🙏',
delay: 2000,
parentIndex: 0
}
];
const commentIds = [];
discussion.forEach((comment, index) => {
setTimeout(() => {
const params = {
'content': comment.content,
'author-id': 'user_' + Date.now() + '_' + index,
'author-name': comment.author
};
if (comment.parentIndex !== undefined && commentIds[comment.parentIndex]) {
params['parent-id'] = commentIds[comment.parentIndex];
}
// Store comment ID for nesting (simplified - in real app would get from response)
commentIds[index] = 'comment_' + Date.now() + '_' + index;
triggerCommentAction('addComment', params);
}, comment.delay);
});
}
// Add Reactions to All Comments
function addReactionsToAll() {
const comments = document.querySelectorAll('.comment-item');
const reactions = ['👍', '❤️', '🎉', '🔥'];
comments.forEach((comment, index) => {
const commentIdAttr = comment.querySelector('[data-param-comment-id]');
if (commentIdAttr) {
const commentId = commentIdAttr.getAttribute('data-param-comment-id');
const randomReaction = reactions[Math.floor(Math.random() * reactions.length)];
setTimeout(() => {
triggerCommentAction('addReaction', {
'comment-id': commentId,
'reaction': randomReaction,
'user-id': 'demo_user_' + Date.now()
});
}, index * 100);
}
});
}
// Change Sort Order
function changeSortOrder(sortBy) {
triggerCommentAction('changeSorting', {
'sort-by': sortBy
});
}
// Initialize with some demo comments on page load
window.addEventListener('DOMContentLoaded', function() {
// Check if there are already comments
const existingComments = document.querySelectorAll('.comment-item');
// Only add demo data if no comments exist
if (existingComments.length === 0) {
setTimeout(() => {
addRandomComment();
}, 500);
setTimeout(() => {
addRandomComment();
}, 1000);
setTimeout(() => {
addThreadDiscussion();
}, 1500);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,524 @@
<div class="comment-thread">
<!-- Thread Header -->
<div class="comment-thread-header">
<div class="comment-stats">
<span class="stat-item">
<strong>{{total_comments}}</strong> Kommentare
</span>
<span class="stat-item">
<strong>{{total_reactions}}</strong> Reaktionen
</span>
</div>
<!-- Sorting Controls -->
<div class="comment-sort">
<label for="sort-select">Sortierung:</label>
<select id="sort-select" data-live-action="changeSorting" onchange="this.dispatchEvent(new Event('change', {bubbles: true}))">
<for items="sort_options" as="option">
<option value="{{option.value}}" {{option.is_selected}}>
{{option.label}}
</option>
</for>
</select>
</div>
</div>
<!-- Comments List -->
<div class="comments-list">
<if condition="has_comments">
<for items="prepared_comments" as="comment">
<include template="components/comment-item" data="comment" />
</for>
</if>
<if condition="!has_comments">
<div class="comments-empty">
<div class="empty-icon">💬</div>
<div class="empty-text">Noch keine Kommentare vorhanden</div>
<p class="empty-hint">Sei der Erste, der einen Kommentar hinterlässt!</p>
</div>
</if>
</div>
<!-- New Comment Form -->
<div class="comment-form">
<h3>Kommentar hinzufügen</h3>
<form id="new-comment-form">
<div class="form-group">
<label for="comment-author">Name</label>
<input type="text" id="comment-author" required placeholder="Dein Name">
</div>
<div class="form-group">
<label for="comment-content">Kommentar</label>
<textarea id="comment-content" rows="4" required placeholder="Was möchtest du sagen?"></textarea>
</div>
<button type="submit" class="btn btn-primary">Kommentar abschicken</button>
</form>
</div>
</div>
<!-- Comment Item Component Template -->
<template id="comment-item-template">
<article class="comment-item" style="{{comment.indent_style}}">
<div class="comment-header">
<span class="comment-author">{{comment.author_name}}</span>
<span class="comment-date">{{comment.formatted_date}}{{comment.edited_label}}</span>
</div>
<div class="comment-content">
{{comment.content}}
</div>
<if condition="comment.show_reactions && comment.has_reactions">
<div class="comment-reactions">
<for items="comment.grouped_reactions" as="reaction">
<button
class="reaction-button"
data-live-action="addReaction"
data-param-comment-id="{{comment.id}}"
data-param-reaction="{{reaction.type}}"
data-param-user-id="current_user"
>
{{reaction.type}} {{reaction.count}}
</button>
</for>
</div>
</if>
<div class="comment-actions">
<if condition="comment.can_reply">
<button
class="action-button"
onclick="toggleReplyForm('{{comment.id}}')"
>
Antworten
</button>
</if>
<if condition="comment.show_reactions">
<button
class="action-button"
data-live-action="addReaction"
data-param-comment-id="{{comment.id}}"
data-param-reaction="👍"
data-param-user-id="current_user"
>
👍 Gefällt mir
</button>
</if>
<if condition="comment.can_edit">
<button
class="action-button"
onclick="editComment('{{comment.id}}')"
>
✏️ Bearbeiten
</button>
</if>
<if condition="comment.can_delete">
<button
class="action-button action-danger"
data-live-action="deleteComment"
data-param-comment-id="{{comment.id}}"
onclick="return confirm('Kommentar wirklich löschen?')"
>
🗑️ Löschen
</button>
</if>
</div>
<!-- Reply Form (hidden by default) -->
<div id="reply-form-{{comment.id}}" class="reply-form" style="display: none;">
<textarea
id="reply-content-{{comment.id}}"
rows="3"
placeholder="Deine Antwort..."
></textarea>
<div class="reply-form-actions">
<button
onclick="submitReply('{{comment.id}}')"
class="btn btn-sm btn-primary"
>
Antworten
</button>
<button
onclick="toggleReplyForm('{{comment.id}}')"
class="btn btn-sm btn-secondary"
>
Abbrechen
</button>
</div>
</div>
<!-- Nested Replies -->
<if condition="comment.has_replies">
<div class="comment-replies">
<for items="comment.replies" as="reply">
<include template="components/comment-item" data="reply" />
</for>
</div>
</if>
</article>
</template>
<style>
.comment-thread {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.comment-thread-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e0e0e0;
}
.comment-stats {
display: flex;
gap: 1.5rem;
}
.stat-item {
color: #666;
font-size: 0.9rem;
}
.stat-item strong {
color: #333;
font-size: 1.1rem;
}
.comment-sort {
display: flex;
align-items: center;
gap: 0.5rem;
}
.comment-sort label {
color: #666;
font-size: 0.9rem;
}
.comment-sort select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.comments-list {
margin-bottom: 2rem;
}
.comment-item {
background: #f9f9f9;
border-left: 3px solid #2196F3;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
transition: all 0.2s;
}
.comment-item:hover {
background: #f5f5f5;
border-left-color: #1976D2;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.comment-author {
font-weight: 600;
color: #333;
}
.comment-date {
color: #999;
font-size: 0.85rem;
}
.comment-content {
color: #555;
line-height: 1.6;
margin-bottom: 0.75rem;
}
.comment-reactions {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.reaction-button {
padding: 0.25rem 0.75rem;
background: white;
border: 1px solid #ddd;
border-radius: 20px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.reaction-button:hover {
background: #f0f0f0;
border-color: #2196F3;
}
.comment-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.action-button {
padding: 0.25rem 0.75rem;
background: transparent;
border: none;
color: #2196F3;
cursor: pointer;
font-size: 0.875rem;
transition: color 0.2s;
}
.action-button:hover {
color: #1976D2;
text-decoration: underline;
}
.action-danger {
color: #f44336;
}
.action-danger:hover {
color: #d32f2f;
}
.reply-form {
margin-top: 1rem;
padding: 1rem;
background: white;
border-radius: 4px;
border: 1px solid #ddd;
}
.reply-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
font-family: inherit;
margin-bottom: 0.5rem;
}
.reply-form-actions {
display: flex;
gap: 0.5rem;
}
.comment-replies {
margin-top: 1rem;
}
.comments-empty {
text-align: center;
padding: 3rem 1rem;
color: #999;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-text {
font-size: 1.125rem;
margin-bottom: 0.5rem;
color: #666;
}
.empty-hint {
font-size: 0.9rem;
color: #999;
}
.comment-form {
background: #f9f9f9;
padding: 1.5rem;
border-radius: 8px;
border: 2px dashed #ddd;
}
.comment-form h3 {
margin-bottom: 1rem;
color: #333;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
}
.form-group textarea {
resize: vertical;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: #2196F3;
color: white;
}
.btn-primary:hover {
background: #1976D2;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-secondary {
background: #9E9E9E;
color: white;
}
.btn-secondary:hover {
background: #757575;
}
@media (max-width: 768px) {
.comment-thread-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.comment-item {
margin-left: 0 !important;
}
.comment-actions {
font-size: 0.8rem;
}
}
</style>
<script>
function toggleReplyForm(commentId) {
const form = document.getElementById(`reply-form-${commentId}`);
if (form) {
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
}
function submitReply(commentId) {
const textarea = document.getElementById(`reply-content-${commentId}`);
const content = textarea.value.trim();
if (!content) {
alert('Bitte gib eine Antwort ein');
return;
}
const component = document.querySelector('[data-component-id^="comment-thread:"]');
const button = document.createElement('button');
button.setAttribute('data-live-action', 'addComment');
button.setAttribute('data-param-content', content);
button.setAttribute('data-param-author-id', 'current_user');
button.setAttribute('data-param-author-name', 'Aktueller Benutzer');
button.setAttribute('data-param-parent-id', commentId);
button.style.display = 'none';
component.appendChild(button);
button.click();
component.removeChild(button);
textarea.value = '';
toggleReplyForm(commentId);
}
function editComment(commentId) {
const newContent = prompt('Neuer Kommentar-Inhalt:');
if (!newContent || !newContent.trim()) {
return;
}
const component = document.querySelector('[data-component-id^="comment-thread:"]');
const button = document.createElement('button');
button.setAttribute('data-live-action', 'editComment');
button.setAttribute('data-param-comment-id', commentId);
button.setAttribute('data-param-new-content', newContent);
button.style.display = 'none';
component.appendChild(button);
button.click();
component.removeChild(button);
}
// New Comment Form Handler
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('new-comment-form');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
const author = document.getElementById('comment-author').value.trim();
const content = document.getElementById('comment-content').value.trim();
if (!author || !content) {
alert('Bitte fülle alle Felder aus');
return;
}
const component = document.querySelector('[data-component-id^="comment-thread:"]');
const button = document.createElement('button');
button.setAttribute('data-live-action', 'addComment');
button.setAttribute('data-param-content', content);
button.setAttribute('data-param-author-id', 'user_' + Date.now());
button.setAttribute('data-param-author-name', author);
button.style.display = 'none';
component.appendChild(button);
button.click();
component.removeChild(button);
// Reset form
form.reset();
});
}
});
</script>

View File

@@ -0,0 +1,148 @@
<div class="counter-component">
<div class="counter-display">
<h2>Counter: <span class="count">{{ count }}</span></h2>
<if condition="lastUpdate">
<p class="last-update">Last update: {{ lastUpdate }}</p>
</if>
</div>
<div class="counter-actions">
<button
data-live-action="decrement"
data-live-prevent
class="btn btn-danger"
>
- Decrement
</button>
<button
data-live-action="increment"
data-live-prevent
class="btn btn-success"
>
+ Increment
</button>
<button
data-live-action="reset"
data-live-prevent
class="btn btn-secondary"
>
Reset
</button>
</div>
<div class="counter-custom">
<form data-live-action="addAmount" data-live-prevent>
<input
type="number"
name="amount"
placeholder="Enter amount"
value="5"
class="form-control"
/>
<button type="submit" class="btn btn-primary">Add Amount</button>
</form>
</div>
</div>
<style>
.counter-component {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.counter-display {
text-align: center;
margin-bottom: 2rem;
}
.counter-display h2 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
color: #333;
}
.counter-display .count {
color: #007bff;
font-weight: bold;
}
.last-update {
font-size: 0.875rem;
color: #666;
margin: 0;
}
.counter-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.counter-actions button {
flex: 1;
}
.counter-custom {
padding-top: 1rem;
border-top: 1px solid #eee;
}
.counter-custom form {
display: flex;
gap: 0.5rem;
}
.counter-custom input {
flex: 1;
}
/* Button Styles */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.btn-success {
background: #28a745;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-primary {
background: #007bff;
color: white;
}
.form-control {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveComponent DataTable Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 2rem;
background: #f5f5f5;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
margin-bottom: 1rem;
color: #333;
}
.intro {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.intro h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #444;
}
.feature-list {
line-height: 1.8;
color: #666;
}
.feature-list li {
margin-bottom: 0.5rem;
}
.demo-section {
margin-bottom: 2rem;
}
</style>
</head>
<body data-module="livecomponent">
<div class="container">
<h1>📊 LiveComponent DataTable Demo</h1>
<div class="intro">
<h2>Advanced Data Table with Real-Time Filtering</h2>
<ul class="feature-list">
<li> <strong>Server-Side Sorting:</strong> Click column headers to sort ascending/descending</li>
<li> <strong>Multi-Column Filtering:</strong> Filter by ID, Name, Email, Role, or Status</li>
<li> <strong>Pagination:</strong> Navigate through data with customizable page sizes</li>
<li> <strong>Row Selection:</strong> Select individual rows or use "Select All"</li>
<li> <strong>Bulk Actions:</strong> Delete multiple selected rows at once</li>
<li> <strong>Debounced Filters:</strong> 300ms delay reduces server requests while typing</li>
<li> <strong>Visual Feedback:</strong> Badges, hover states, and sort indicators</li>
</ul>
</div>
<div class="demo-section">
{{datatable}}
</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More