docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class ComponentName
{
public function __construct(
public string $tag
) {
}
}

View File

@@ -14,6 +14,7 @@ use App\Framework\View\Caching\Strategies\FragmentCacheStrategy;
use App\Framework\View\Caching\Strategies\FullPageCacheStrategy;
use App\Framework\View\Caching\Strategies\NoCacheStrategy;
use App\Framework\View\Caching\Strategies\ViewCacheStrategy;
use App\Framework\View\Exceptions\TemplateCacheException;
class CacheManager
{
@@ -32,17 +33,33 @@ class CacheManager
public function render(TemplateContext $context, callable $renderer): string
{
// DEBUG: Log cache analysis for routes template
if ($context->template === 'routes') {
error_log("CacheManager::render - Template: routes");
}
// 1. Template analysieren für optimale Strategy
$analysis = $this->analyzer->analyze($context->template);
$this->lastAnalysis = $analysis;
// DEBUG: Log analysis results for routes template
if ($context->template === 'routes') {
error_log("CacheManager::render - Strategy: " . $analysis->recommendedStrategy->name);
error_log("CacheManager::render - Cacheable: " . ($analysis->cacheability->isCacheable() ? 'YES' : 'NO'));
error_log("CacheManager::render - Static ratio: " . $analysis->cacheability->staticContentRatio);
}
// 2. Passende Strategy auswählen
$strategy = $this->selectStrategy($analysis);
if (! $strategy->shouldCache($context)) {
$content = $renderer();
if (! is_string($content)) {
throw new \RuntimeException('Renderer must return a string, got: ' . get_debug_type($content));
throw TemplateCacheException::invalidCacheFormat(
'non_cached_content',
'string',
get_debug_type($content)
);
}
return $content;
@@ -55,14 +72,27 @@ class CacheManager
$result = $this->cache->get($cacheKey);
$cached = $result->getItem($cacheKey);
if ($cached->isHit && is_string($cached->value)) {
// DEBUG: Log cache hit for routes template
if ($context->template === 'routes') {
error_log("CacheManager::render - CACHE HIT! Returning cached content (length: " . strlen($cached->value) . ")");
error_log("CacheManager::render - First 200 chars: " . substr($cached->value, 0, 200));
}
return $cached->value;
}
// 5. Rendern und cachen
if ($context->template === 'routes') {
error_log("CacheManager::render - CACHE MISS! Calling renderer()");
}
$content = $renderer();
if (! is_string($content)) {
throw new \RuntimeException('Renderer must return a string, got: ' . get_debug_type($content));
throw TemplateCacheException::invalidCacheFormat(
$cacheKey,
'string',
get_debug_type($content)
);
}
$ttl = $strategy->getTtl($context);

View File

@@ -32,7 +32,17 @@ final readonly class ComponentRenderer
$template = $this->storage->get($path);
$context = new RenderContext(template: $componentName, metaData: new MetaData(''), data: $data);
$output = $this->processor->render($context, $template);
file_put_contents('/tmp/debug.log', "ComponentRenderer::render - Component: $componentName, Template length: " . strlen($template) . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "ComponentRenderer::render - Template starts with: " . substr($template, 0, 100) . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "ComponentRenderer::render - Data keys: " . implode(', ', array_keys($data)) . "\n", FILE_APPEND | LOCK_EX);
if (isset($data['bodyContent'])) {
file_put_contents('/tmp/debug.log', "ComponentRenderer::render - bodyContent length: " . strlen($data['bodyContent']) . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "ComponentRenderer::render - bodyContent starts: " . substr($data['bodyContent'], 0, 200) . "\n", FILE_APPEND | LOCK_EX);
}
file_put_contents('/tmp/debug.log', "ComponentRenderer::render - About to call processor->render (component=true)\n", FILE_APPEND | LOCK_EX);
$output = $this->processor->render($context, $template, true);
// Ensure we never return null
if ($output === null) {

View File

@@ -0,0 +1,153 @@
<?php
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;
#[ComponentName('alert')]
final readonly class Alert implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
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()
) {
$this->tag = new HtmlTag(TagName::DIV);
$classes = ['alert', "alert--{$this->variant}"];
if ($this->dismissible) {
$classes[] = 'alert--dismissible';
}
$this->attributes = HtmlAttributes::empty()
->withClass(implode(' ', $classes))
->with('role', 'alert');
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
$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
);
}
private function buildContent(): string
{
$elements = [];
// Icon
if ($this->icon !== null) {
$elements[] = StandardHtmlElement::create(
TagName::SPAN,
HtmlAttributes::empty()->withClass('alert__icon'),
$this->icon
);
}
// Content wrapper
$contentElements = [];
// Title
if ($this->title !== null) {
$contentElements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('alert__title'),
$this->title
);
}
// Message
$contentElements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('alert__message'),
$this->message
);
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('alert__content'),
implode('', array_map('strval', $contentElements))
);
// Dismiss button
if ($this->dismissible) {
$dismissButton = StandardHtmlElement::create(
TagName::BUTTON,
HtmlAttributes::empty()
->withType('button')
->withClass('alert__close')
->with('aria-label', 'Schließen'),
'×'
);
$elements[] = $dismissButton;
}
return implode('', array_map('strval', $elements));
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,102 @@
<?php
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;
#[ComponentName('badge')]
final readonly class Badge implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(
public string $text,
public string $variant = 'default',
public string $size = 'md',
public bool $pill = false,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::SPAN);
$this->content = $text;
$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);
}
}
public static function create(string $text): self
{
return new self(text: $text);
}
public static function primary(string $text): self
{
return new self(text: $text, variant: 'primary');
}
public static function success(string $text): self
{
return new self(text: $text, variant: 'success');
}
public static function warning(string $text): self
{
return new self(text: $text, variant: 'warning');
}
public static function danger(string $text): self
{
return new self(text: $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
);
}
}

View File

@@ -0,0 +1,187 @@
<?php
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;
#[ComponentName('button')]
final readonly class Button implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
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()
) {
// Determine tag based on href
$this->tag = $this->href !== null
? new HtmlTag(TagName::A)
: new HtmlTag(TagName::BUTTON);
// Build classes
$classes = ['btn', "btn--{$this->variant}", "btn--{$this->size}"];
if ($this->fullWidth) {
$classes[] = 'btn--full-width';
}
// Build attributes
$attributes = HtmlAttributes::empty()
->withClass(implode(' ', $classes));
if ($this->href !== null) {
$attributes = $attributes->with('href', $this->href);
if ($this->disabled) {
$attributes = $attributes
->withClass('btn--disabled')
->with('aria-disabled', 'true');
}
} else {
$attributes = $attributes->withType($this->type);
if ($this->disabled) {
$attributes = $attributes->withDisabled();
}
}
// 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;
}
public static function primary(string $text): self
{
return new self(text: $text, variant: 'primary');
}
public static function secondary(string $text): self
{
return new self(text: $text, variant: 'secondary');
}
public static function danger(string $text): self
{
return new self(text: $text, variant: 'danger');
}
public static function success(string $text): self
{
return new self(text: $text, variant: 'success');
}
public static function ghost(string $text): self
{
return new self(text: $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
);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class ButtonGroup implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
/**
* @param array<Button> $buttons
*/
public function __construct(
public array $buttons,
public string $size = 'md',
public bool $vertical = false,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::DIV);
$classes = ['btn-group', "btn-group--{$this->size}"];
if ($this->vertical) {
$classes[] = 'btn-group--vertical';
}
$this->attributes = HtmlAttributes::empty()
->withClass(implode(' ', $classes))
->with('role', 'group');
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
$buttonsHtml = array_map(
fn(Button $button) => (string) $button,
$this->buttons
);
$this->content = implode('', $buttonsHtml);
}
public static function create(Button ...$buttons): self
{
return new self(buttons: $buttons);
}
public function withSize(string $size): self
{
return new self(
buttons: $this->buttons,
size: $size,
vertical: $this->vertical,
additionalAttributes: $this->additionalAttributes
);
}
public function asVertical(): self
{
return new self(
buttons: $this->buttons,
size: $this->size,
vertical: true,
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,173 @@
<?php
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;
#[ComponentName('card')]
final readonly class Card implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
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()
) {
$this->tag = new HtmlTag(TagName::DIV);
$classes = ['card', "card--{$this->variant}"];
$this->attributes = HtmlAttributes::empty()
->withClass(implode(' ', $classes));
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
$this->content = $this->buildContent($bodyContent);
}
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
{
$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);
}
// Header (title + subtitle)
if ($this->title !== null || $this->subtitle !== null) {
$headerElements = [];
if ($this->title !== null) {
$headerElements[] = StandardHtmlElement::create(
TagName::H3,
HtmlAttributes::empty()->withClass('card__title'),
$this->title
);
}
if ($this->subtitle !== null) {
$headerElements[] = StandardHtmlElement::create(
TagName::P,
HtmlAttributes::empty()->withClass('card__subtitle'),
$this->subtitle
);
}
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('card__header'),
implode('', array_map('strval', $headerElements))
);
}
// Body
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('card__body'),
$bodyContent
);
// Footer
if ($this->footer !== null) {
$elements[] = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::empty()->withClass('card__footer'),
$this->footer
);
}
return implode('', array_map('strval', $elements));
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class Container implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public function __construct(
public string $content,
public string $size = 'default',
public bool $fluid = false,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::DIV);
$classes = ['container'];
if ($this->fluid) {
$classes[] = 'container--fluid';
} else {
$classes[] = "container--{$this->size}";
}
$this->attributes = HtmlAttributes::empty()->withClass(implode(' ', $classes));
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
}
public static function create(string $content): self
{
return new self(content: $content);
}
public static function fluid(string $content): self
{
return new self(content: $content, fluid: true);
}
public static function small(string $content): self
{
return new self(content: $content, size: 'sm');
}
public static function large(string $content): self
{
return new self(content: $content, size: 'lg');
}
public function withSize(string $size): self
{
return new self(
content: $this->content,
size: $size,
fluid: $this->fluid,
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
TagName::DIV,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class FormCheckbox implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(
public string $name,
public string $value = '1',
public string $label = '',
public bool $checked = false,
public ?string $id = null,
public bool $required = false,
public bool $disabled = false,
public ?string $errorMessage = null,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::DIV);
$this->attributes = HtmlAttributes::empty()->withClass('form-checkbox-group');
$this->content = $this->buildContent();
}
public static function create(
string $name,
string $label,
string $value = '1'
): self {
return new self(name: $name, value: $value, label: $label);
}
public function withChecked(bool $checked = true): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
checked: $checked,
id: $this->id,
required: $this->required,
disabled: $this->disabled,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withRequired(bool $required = true): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
checked: $this->checked,
id: $this->id,
required: $required,
disabled: $this->disabled,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withError(string $errorMessage): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
checked: $this->checked,
id: $this->id,
required: $this->required,
disabled: $this->disabled,
errorMessage: $errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
private function buildContent(): string
{
$checkboxId = $this->id ?? "checkbox-{$this->name}";
$attributes = HtmlAttributes::empty()
->withType('checkbox')
->withName($this->name)
->withValue($this->value)
->withId($checkboxId)
->withClass('form-checkbox');
if ($this->checked) {
$attributes = $attributes->withChecked();
}
if ($this->required) {
$attributes = $attributes->withRequired();
}
if ($this->disabled) {
$attributes = $attributes->withDisabled();
}
if ($this->errorMessage !== null) {
$attributes = $attributes
->withClass('form-checkbox--error')
->with('aria-invalid', 'true')
->with('aria-describedby', "{$checkboxId}-error");
}
// Merge additional attributes
foreach ($this->additionalAttributes->attributes as $name => $value) {
$attributes = $attributes->with($name, $value);
}
$elements = [];
// Checkbox input
$elements[] = StandardHtmlElement::create(TagName::INPUT, $attributes);
// Label
$labelAttrs = HtmlAttributes::empty()
->withClass('form-checkbox-label')
->with('for', $checkboxId);
$elements[] = StandardHtmlElement::create(
TagName::LABEL,
$labelAttrs,
$this->label
);
// Error message
if ($this->errorMessage !== null) {
$elements[] = StandardHtmlElement::create(
TagName::SPAN,
HtmlAttributes::empty()
->withClass('form-error')
->withId("{$checkboxId}-error")
->with('role', 'alert'),
$this->errorMessage
);
}
return implode('', array_map('strval', $elements));
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class FormInput implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(
public string $name,
public string $type = 'text',
public ?string $value = null,
public ?string $label = null,
public ?string $id = null,
public ?string $placeholder = null,
public bool $required = false,
public bool $disabled = false,
public bool $readonly = false,
public ?string $pattern = null,
public ?int $minLength = null,
public ?int $maxLength = null,
public ?string $errorMessage = null,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::DIV);
$this->attributes = HtmlAttributes::empty()->withClass('form-group');
$this->content = $this->buildContent();
}
public static function text(
string $name,
?string $label = null,
?string $value = null
): self {
return new self(name: $name, type: 'text', label: $label, value: $value);
}
public static function email(
string $name,
?string $label = null,
?string $value = null
): self {
return new self(name: $name, type: 'email', label: $label, value: $value);
}
public static function password(
string $name,
?string $label = null
): self {
return new self(name: $name, type: 'password', label: $label);
}
public static function number(
string $name,
?string $label = null,
?string $value = null
): self {
return new self(name: $name, type: 'number', label: $label, value: $value);
}
public static function tel(
string $name,
?string $label = null,
?string $value = null
): self {
return new self(name: $name, type: 'tel', label: $label, value: $value);
}
public static function url(
string $name,
?string $label = null,
?string $value = null
): self {
return new self(name: $name, type: 'url', label: $label, value: $value);
}
public static function date(
string $name,
?string $label = null,
?string $value = null
): self {
return new self(name: $name, type: 'date', label: $label, value: $value);
}
public static function search(
string $name,
?string $label = null,
?string $placeholder = null
): self {
return new self(name: $name, type: 'search', label: $label, placeholder: $placeholder);
}
public function withRequired(bool $required = true): self
{
return new self(
name: $this->name,
type: $this->type,
value: $this->value,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
required: $required,
disabled: $this->disabled,
readonly: $this->readonly,
pattern: $this->pattern,
minLength: $this->minLength,
maxLength: $this->maxLength,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withPattern(string $pattern): self
{
return new self(
name: $this->name,
type: $this->type,
value: $this->value,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
required: $this->required,
disabled: $this->disabled,
readonly: $this->readonly,
pattern: $pattern,
minLength: $this->minLength,
maxLength: $this->maxLength,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withError(string $errorMessage): self
{
return new self(
name: $this->name,
type: $this->type,
value: $this->value,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
required: $this->required,
disabled: $this->disabled,
readonly: $this->readonly,
pattern: $this->pattern,
minLength: $this->minLength,
maxLength: $this->maxLength,
errorMessage: $errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
private function buildContent(): string
{
$inputId = $this->id ?? "input-{$this->name}";
$attributes = HtmlAttributes::empty()
->withType($this->type)
->withName($this->name)
->withId($inputId)
->withClass('form-input');
if ($this->value !== null) {
$attributes = $attributes->withValue($this->value);
}
if ($this->placeholder !== null) {
$attributes = $attributes->with('placeholder', $this->placeholder);
}
if ($this->required) {
$attributes = $attributes->withRequired();
}
if ($this->disabled) {
$attributes = $attributes->withDisabled();
}
if ($this->readonly) {
$attributes = $attributes->withReadonly();
}
if ($this->pattern !== null) {
$attributes = $attributes->with('pattern', $this->pattern);
}
if ($this->minLength !== null) {
$attributes = $attributes->with('minlength', (string) $this->minLength);
}
if ($this->maxLength !== null) {
$attributes = $attributes->with('maxlength', (string) $this->maxLength);
}
if ($this->errorMessage !== null) {
$attributes = $attributes
->withClass('form-input--error')
->with('aria-invalid', 'true')
->with('aria-describedby', "{$inputId}-error");
}
// Merge additional attributes
foreach ($this->additionalAttributes->attributes as $name => $value) {
$attributes = $attributes->with($name, $value);
}
$elements = [];
// Label
if ($this->label !== null) {
$labelAttrs = HtmlAttributes::empty()
->withClass('form-label')
->with('for', $inputId);
if ($this->required) {
$labelAttrs = $labelAttrs->withClass('form-label--required');
}
$elements[] = StandardHtmlElement::create(
TagName::LABEL,
$labelAttrs,
$this->label
);
}
// Input
$elements[] = StandardHtmlElement::create(TagName::INPUT, $attributes);
// Error message
if ($this->errorMessage !== null) {
$elements[] = StandardHtmlElement::create(
TagName::SPAN,
HtmlAttributes::empty()
->withClass('form-error')
->withId("{$inputId}-error")
->with('role', 'alert'),
$this->errorMessage
);
}
return implode('', array_map('strval', $elements));
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class FormRadio implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(
public string $name,
public string $value,
public string $label = '',
public bool $checked = false,
public ?string $id = null,
public bool $required = false,
public bool $disabled = false,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::DIV);
$this->attributes = HtmlAttributes::empty()->withClass('form-radio-item');
$this->content = $this->buildContent();
}
private function buildContent(): string
{
$radioId = $this->id ?? "radio-{$this->name}-{$this->value}";
$elements = [];
// Radio input
$radioAttrs = HtmlAttributes::empty()
->withType('radio')
->withName($this->name)
->withValue($this->value)
->withId($radioId)
->withClass('form-radio');
if ($this->checked) {
$radioAttrs = $radioAttrs->withChecked();
}
if ($this->required) {
$radioAttrs = $radioAttrs->withRequired();
}
if ($this->disabled) {
$radioAttrs = $radioAttrs->withDisabled();
}
foreach ($this->additionalAttributes->attributes as $name => $value) {
$radioAttrs = $radioAttrs->with($name, $value);
}
$elements[] = StandardHtmlElement::create(TagName::INPUT, $radioAttrs);
// Label
$elements[] = StandardHtmlElement::create(
TagName::LABEL,
HtmlAttributes::empty()->withClass('form-radio-label')->with('for', $radioId),
$this->label
);
return implode('', array_map('strval', $elements));
}
public static function create(
string $name,
string $value,
string $label
): self {
return new self(name: $name, value: $value, label: $label);
}
public function withChecked(bool $checked = true): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
checked: $checked,
id: $this->id,
required: $this->required,
disabled: $this->disabled,
additionalAttributes: $this->additionalAttributes
);
}
public function withRequired(bool $required = true): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
checked: $this->checked,
id: $this->id,
required: $required,
disabled: $this->disabled,
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class FormSelect implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
/**
* @param array<string, string> $options Key-value pairs for options
*/
public function __construct(
public string $name,
public array $options,
public ?string $selected = null,
public ?string $label = null,
public ?string $id = null,
public ?string $placeholder = null,
public bool $required = false,
public bool $disabled = false,
public bool $multiple = false,
public ?string $errorMessage = null,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::DIV);
$this->attributes = HtmlAttributes::empty()->withClass('form-group');
$this->content = $this->buildContent();
}
public static function create(
string $name,
array $options,
?string $label = null
): self {
return new self(name: $name, options: $options, label: $label);
}
public function withSelected(string $selected): self
{
return new self(
name: $this->name,
options: $this->options,
selected: $selected,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
required: $this->required,
disabled: $this->disabled,
multiple: $this->multiple,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withRequired(bool $required = true): self
{
return new self(
name: $this->name,
options: $this->options,
selected: $this->selected,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
required: $required,
disabled: $this->disabled,
multiple: $this->multiple,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withMultiple(bool $multiple = true): self
{
return new self(
name: $this->name,
options: $this->options,
selected: $this->selected,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
required: $this->required,
disabled: $this->disabled,
multiple: $multiple,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withError(string $errorMessage): self
{
return new self(
name: $this->name,
options: $this->options,
selected: $this->selected,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
required: $this->required,
disabled: $this->disabled,
multiple: $this->multiple,
errorMessage: $errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
private function buildContent(): string
{
$selectId = $this->id ?? "select-{$this->name}";
$attributes = HtmlAttributes::empty()
->withName($this->name)
->withId($selectId)
->withClass('form-select');
if ($this->required) {
$attributes = $attributes->withRequired();
}
if ($this->disabled) {
$attributes = $attributes->withDisabled();
}
if ($this->multiple) {
$attributes = $attributes->with('multiple', 'multiple');
}
if ($this->errorMessage !== null) {
$attributes = $attributes
->withClass('form-select--error')
->with('aria-invalid', 'true')
->with('aria-describedby', "{$selectId}-error");
}
// Merge additional attributes
foreach ($this->additionalAttributes->attributes as $name => $value) {
$attributes = $attributes->with($name, $value);
}
// Build options
$optionElements = [];
if ($this->placeholder !== null) {
$optionElements[] = StandardHtmlElement::create(
TagName::OPTION,
HtmlAttributes::empty()
->withValue('')
->withDisabled()
->withSelected(),
$this->placeholder
);
}
foreach ($this->options as $value => $text) {
$optionAttrs = HtmlAttributes::empty()->withValue($value);
if ($this->selected !== null && $value === $this->selected) {
$optionAttrs = $optionAttrs->withSelected();
}
$optionElements[] = StandardHtmlElement::create(
TagName::OPTION,
$optionAttrs,
$text
);
}
$selectContent = implode('', array_map('strval', $optionElements));
$elements = [];
// Label
if ($this->label !== null) {
$labelAttrs = HtmlAttributes::empty()
->withClass('form-label')
->with('for', $selectId);
if ($this->required) {
$labelAttrs = $labelAttrs->withClass('form-label--required');
}
$elements[] = StandardHtmlElement::create(
TagName::LABEL,
$labelAttrs,
$this->label
);
}
// Select
$elements[] = StandardHtmlElement::create(
TagName::SELECT,
$attributes,
$selectContent
);
// Error message
if ($this->errorMessage !== null) {
$elements[] = StandardHtmlElement::create(
TagName::SPAN,
HtmlAttributes::empty()
->withClass('form-error')
->withId("{$selectId}-error")
->with('role', 'alert'),
$this->errorMessage
);
}
return implode('', array_map('strval', $elements));
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class FormTextarea implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(
public string $name,
public ?string $value = null,
public ?string $label = null,
public ?string $id = null,
public ?string $placeholder = null,
public int $rows = 4,
public ?int $cols = null,
public bool $required = false,
public bool $disabled = false,
public bool $readonly = false,
public ?int $minLength = null,
public ?int $maxLength = null,
public ?string $errorMessage = null,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::DIV);
$this->attributes = HtmlAttributes::empty()->withClass('form-group');
$this->content = $this->buildContent();
}
private function buildContent(): string
{
$textareaId = $this->id ?? "textarea-{$this->name}";
$elements = [];
// Label
if ($this->label !== null) {
$labelAttrs = HtmlAttributes::empty()
->withClass('form-label')
->with('for', $textareaId);
if ($this->required) {
$labelAttrs = $labelAttrs->withClass('form-label--required');
}
$elements[] = StandardHtmlElement::create(TagName::LABEL, $labelAttrs, $this->label);
}
// Textarea
$textareaAttrs = HtmlAttributes::empty()
->withName($this->name)
->withId($textareaId)
->withClass('form-textarea')
->with('rows', (string) $this->rows);
if ($this->cols !== null) {
$textareaAttrs = $textareaAttrs->with('cols', (string) $this->cols);
}
if ($this->placeholder !== null) {
$textareaAttrs = $textareaAttrs->with('placeholder', $this->placeholder);
}
if ($this->required) {
$textareaAttrs = $textareaAttrs->withRequired();
}
if ($this->disabled) {
$textareaAttrs = $textareaAttrs->withDisabled();
}
if ($this->readonly) {
$textareaAttrs = $textareaAttrs->withReadonly();
}
if ($this->minLength !== null) {
$textareaAttrs = $textareaAttrs->with('minlength', (string) $this->minLength);
}
if ($this->maxLength !== null) {
$textareaAttrs = $textareaAttrs->with('maxlength', (string) $this->maxLength);
}
if ($this->errorMessage !== null) {
$textareaAttrs = $textareaAttrs->withClass('form-textarea--error')
->with('aria-invalid', 'true')
->with('aria-describedby', "{$textareaId}-error");
}
foreach ($this->additionalAttributes->attributes as $name => $value) {
$textareaAttrs = $textareaAttrs->with($name, $value);
}
$elements[] = StandardHtmlElement::create(TagName::TEXTAREA, $textareaAttrs, $this->value ?? '');
// Error message
if ($this->errorMessage !== null) {
$elements[] = StandardHtmlElement::create(
TagName::SPAN,
HtmlAttributes::empty()
->withClass('form-error')
->withId("{$textareaId}-error")
->with('role', 'alert'),
$this->errorMessage
);
}
return implode('', array_map('strval', $elements));
}
public static function create(
string $name,
?string $label = null,
?string $value = null
): self {
return new self(name: $name, label: $label, value: $value);
}
public function withRows(int $rows): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
rows: $rows,
cols: $this->cols,
required: $this->required,
disabled: $this->disabled,
readonly: $this->readonly,
minLength: $this->minLength,
maxLength: $this->maxLength,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withRequired(bool $required = true): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
rows: $this->rows,
cols: $this->cols,
required: $required,
disabled: $this->disabled,
readonly: $this->readonly,
minLength: $this->minLength,
maxLength: $this->maxLength,
errorMessage: $this->errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function withError(string $errorMessage): self
{
return new self(
name: $this->name,
value: $this->value,
label: $this->label,
id: $this->id,
placeholder: $this->placeholder,
rows: $this->rows,
cols: $this->cols,
required: $this->required,
disabled: $this->disabled,
readonly: $this->readonly,
minLength: $this->minLength,
maxLength: $this->maxLength,
errorMessage: $errorMessage,
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class Image implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
public function __construct(
public string $src,
public string $alt,
public ?int $width = null,
public ?int $height = null,
public string $loading = 'lazy',
public string $decoding = 'async',
public ?string $srcset = null,
public ?string $sizes = null,
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::IMG);
$this->attributes = HtmlAttributes::empty()
->with('src', $this->src)
->with('alt', $this->alt)
->with('loading', $this->loading)
->with('decoding', $this->decoding);
if ($this->width !== null) {
$this->attributes = $this->attributes->with('width', (string) $this->width);
}
if ($this->height !== null) {
$this->attributes = $this->attributes->with('height', (string) $this->height);
}
if ($this->srcset !== null) {
$this->attributes = $this->attributes->with('srcset', $this->srcset);
}
if ($this->sizes !== null) {
$this->attributes = $this->attributes->with('sizes', $this->sizes);
}
// Merge additional attributes
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
$this->content = '';
}
public static function create(string $src, string $alt): self
{
return new self(src: $src, alt: $alt);
}
public static function responsive(
string $src,
string $alt,
string $srcset,
string $sizes = '100vw'
): self {
return new self(
src: $src,
alt: $alt,
srcset: $srcset,
sizes: $sizes
);
}
public function withDimensions(int $width, int $height): self
{
return new self(
src: $this->src,
alt: $this->alt,
width: $width,
height: $height,
loading: $this->loading,
decoding: $this->decoding,
srcset: $this->srcset,
sizes: $this->sizes,
additionalAttributes: $this->additionalAttributes
);
}
public function eager(): self
{
return new self(
src: $this->src,
alt: $this->alt,
width: $this->width,
height: $this->height,
loading: 'eager',
decoding: $this->decoding,
srcset: $this->srcset,
sizes: $this->sizes,
additionalAttributes: $this->additionalAttributes
);
}
public function syncDecoding(): self
{
return new self(
src: $this->src,
alt: $this->alt,
width: $this->width,
height: $this->height,
loading: $this->loading,
decoding: 'sync',
srcset: $this->srcset,
sizes: $this->sizes,
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
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;
final readonly class Picture implements HtmlElement
{
public HtmlTag $tag;
public HtmlAttributes $attributes;
public string $content;
/**
* @param array<PictureSource> $sources
*/
public function __construct(
public string $src,
public string $alt,
public array $sources = [],
public ?int $width = null,
public ?int $height = null,
public string $loading = 'lazy',
public HtmlAttributes $additionalAttributes = new HtmlAttributes()
) {
$this->tag = new HtmlTag(TagName::PICTURE);
$this->attributes = HtmlAttributes::empty();
// Merge additional attributes
foreach ($this->additionalAttributes->attributes as $name => $value) {
$this->attributes = $this->attributes->with($name, $value);
}
$this->content = $this->buildContent();
}
private function buildContent(): string
{
$elements = [];
// Add all source elements
foreach ($this->sources as $source) {
$elements[] = (string) $source;
}
// Add fallback img element
$imgAttrs = HtmlAttributes::empty()
->with('src', $this->src)
->with('alt', $this->alt)
->with('loading', $this->loading);
if ($this->width !== null) {
$imgAttrs = $imgAttrs->with('width', (string) $this->width);
}
if ($this->height !== null) {
$imgAttrs = $imgAttrs->with('height', (string) $this->height);
}
$elements[] = StandardHtmlElement::create(TagName::IMG, $imgAttrs);
return implode('', array_map('strval', $elements));
}
public static function create(string $src, string $alt): self
{
return new self(src: $src, alt: $alt);
}
public static function withWebP(
string $webpSrc,
string $fallbackSrc,
string $alt
): self {
return new self(
src: $fallbackSrc,
alt: $alt,
sources: [
PictureSource::webp($webpSrc),
]
);
}
public static function withAvif(
string $avifSrc,
string $webpSrc,
string $fallbackSrc,
string $alt
): self {
return new self(
src: $fallbackSrc,
alt: $alt,
sources: [
PictureSource::avif($avifSrc),
PictureSource::webp($webpSrc),
]
);
}
public function withSource(PictureSource $source): self
{
return new self(
src: $this->src,
alt: $this->alt,
sources: [...$this->sources, $source],
width: $this->width,
height: $this->height,
loading: $this->loading,
additionalAttributes: $this->additionalAttributes
);
}
public function withDimensions(int $width, int $height): self
{
return new self(
src: $this->src,
alt: $this->alt,
sources: $this->sources,
width: $width,
height: $height,
loading: $this->loading,
additionalAttributes: $this->additionalAttributes
);
}
public function eager(): self
{
return new self(
src: $this->src,
alt: $this->alt,
sources: $this->sources,
width: $this->width,
height: $this->height,
loading: 'eager',
additionalAttributes: $this->additionalAttributes
);
}
public function __toString(): string
{
return (string) StandardHtmlElement::create(
$this->tag->name,
$this->attributes,
$this->content
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\TagName;
final readonly class PictureSource
{
public function __construct(
public string $srcset,
public ?string $type = null,
public ?string $media = null,
public ?string $sizes = null
) {}
public static function webp(string $srcset, ?string $media = null): self
{
return new self(srcset: $srcset, type: 'image/webp', media: $media);
}
public static function avif(string $srcset, ?string $media = null): self
{
return new self(srcset: $srcset, type: 'image/avif', media: $media);
}
public static function jpeg(string $srcset, ?string $media = null): self
{
return new self(srcset: $srcset, type: 'image/jpeg', media: $media);
}
public static function png(string $srcset, ?string $media = null): self
{
return new self(srcset: $srcset, type: 'image/png', media: $media);
}
public static function responsive(
string $srcset,
string $media,
?string $type = null
): self {
return new self(srcset: $srcset, type: $type, media: $media);
}
public function __toString(): string
{
$attributes = HtmlAttributes::empty()
->with('srcset', $this->srcset);
if ($this->type !== null) {
$attributes = $attributes->with('type', $this->type);
}
if ($this->media !== null) {
$attributes = $attributes->with('media', $this->media);
}
if ($this->sizes !== null) {
$attributes = $attributes->with('sizes', $this->sizes);
}
return (string) StandardHtmlElement::create(TagName::SOURCE, $attributes);
}
}

View File

@@ -10,7 +10,13 @@ final readonly class DomComponentService
{
public function replaceComponent(DomWrapper $dom, Element $component, string $html): void
{
file_put_contents('/tmp/debug.log', "DomComponentService::replaceComponent - Component name: " . $component->getAttribute('name') . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "DomComponentService::replaceComponent - HTML length: " . strlen($html) . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "DomComponentService::replaceComponent - HTML starts: " . substr($html, 0, 200) . "\n", FILE_APPEND | LOCK_EX);
$dom->replaceElementWithHtml($component, $html);
file_put_contents('/tmp/debug.log', "DomComponentService::replaceComponent - Replacement completed\n", FILE_APPEND | LOCK_EX);
}
public function processSlots(DomWrapper $dom): void

View File

@@ -32,12 +32,38 @@ final readonly class DomHeadService
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);
$head = $dom->getElementsByTagName('head')->first();
$head?->appendChild($link);
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
@@ -50,8 +76,13 @@ final readonly class DomHeadService
$script->setAttribute($name, $value);
}
$head = $dom->getElementsByTagName('head')->first();
$head?->appendChild($script);
// 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

View File

@@ -10,7 +10,6 @@ use App\Framework\Core\PathProvider;
use App\Framework\DI\Container;
use App\Framework\DI\DefaultContainer;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Performance\PerformanceService;
use App\Framework\View\Caching\Analysis\SmartTemplateAnalyzer;
use App\Framework\View\Caching\CacheManager;
@@ -36,7 +35,7 @@ final readonly class Engine implements TemplateRenderer
#private bool $useSmartCache = true,
#private bool $legacyCacheEnabled = false,
?Cache $cache = null,
private bool $cacheEnabled = true,
private bool $cacheEnabled = false,
) {
// Stelle sicher, dass das Cache-Verzeichnis existiert
#if (! is_dir($this->cachePath)) {
@@ -60,33 +59,31 @@ final readonly class Engine implements TemplateRenderer
public function render(RenderContext $context): string
{
$templateContext = new TemplateContext(
template: $context->template,
data: $context->data,
controllerClass: $context->controllerClass,
metadata: $context->metaData ? ['meta' => $context->metaData] : []
);
// Use cache manager if enabled, otherwise render directly
if ($this->cacheManager !== null) {
$templateContext = new TemplateContext(
template: $context->template,
data: $context->data,
controllerClass: $context->controllerClass,
metadata: $context->metaData ? ['meta' => $context->metaData] : []
);
return $this->cacheManager->render($templateContext, function () use ($context) {
return $this->renderDirect($context);
});
return $this->cacheManager->render($templateContext, function () use ($context) {
return $this->renderDirect($context);
});
}
// Fallback to direct rendering when cache is disabled
return $this->renderDirect($context);
}
private function renderDirect(RenderContext $context): string
{
// Optimized single-pass rendering
return $this->performanceService->measure(
'template_render',
function () use ($context) {
// Load template content
$content = $this->loader->load($context->template, $context->controllerClass, $context);
// Load template content
$content = $this->loader->load($context->template, $context->controllerClass, $context);
// Direct processing without intermediate DOM parsing
return $this->processor->render($context, $content);
},
PerformanceCategory::VIEW,
['template' => $context->template]
);
// Process template through DOM pipeline
return $this->processor->render($context, $content);
}
public function invalidateCache(?string $template = null): int
@@ -108,6 +105,10 @@ final readonly class Engine implements TemplateRenderer
public function renderPartial(RenderContext $context): string
{
// CRITICAL DEBUG - Check if renderPartial is being called instead of render
file_put_contents('/tmp/debug.log', "=== ENGINE::RENDERPARTIAL ENTRY POINT === Template: " . $context->template . "\n", FILE_APPEND | LOCK_EX);
error_log("=== ENGINE::RENDERPARTIAL ENTRY POINT === Template: " . $context->template);
return $this->renderDirect($context);
}
}

View File

@@ -4,16 +4,47 @@ declare(strict_types=1);
namespace App\Framework\View\Exception;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
class TemplateNotFound extends FrameworkException
/**
* Legacy Template Exception - wird zu TemplateNotFoundException migriert
* @deprecated Use TemplateNotFoundException instead
*/
final class TemplateNotFound extends FrameworkException
{
protected string $template;
public static function forTemplate(string $template, ?string $searchPath = null, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_loading', 'TemplateLoader')
->withData([
'template' => $template,
'search_path' => $searchPath,
'timestamp' => microtime(true),
]);
return self::fromContext(
"Das Template '$template' konnte nicht geladen werden.",
$context,
ErrorCode::TPL_TEMPLATE_NOT_FOUND,
$previous
);
}
/**
* Legacy constructor für Backward Compatibility
* @deprecated Use forTemplate() factory method instead
*/
public function __construct(string $template, ?\Throwable $previous = null, int $code = 0, array $context = [])
{
$this->template = $template;
$message = "Das Template '$template' konnte nicht geladen werden.";
parent::__construct($message, $code, $previous, $context);
$exceptionContext = ExceptionContext::forOperation('template_loading', 'TemplateLoader')
->withData(['template' => $template] + $context);
parent::__construct(
message: "Das Template '$template' konnte nicht geladen werden.",
context: $exceptionContext,
errorCode: ErrorCode::TPL_TEMPLATE_NOT_FOUND,
previous: $previous
);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Template Cache Exception für Cache-spezifische Fehler
*/
final class TemplateCacheException extends FrameworkException
{
public static function cacheWriteFailed(string $cacheKey, string $cachePath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_write', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'cache_path' => $cachePath,
'operation' => 'write',
])
->withMetadata([
'recovery_strategy' => 'fallback_to_uncached',
'cache_type' => 'template',
]);
return self::fromContext(
"Template cache write failed for key '$cacheKey'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
$previous
);
}
public static function cacheReadFailed(string $cacheKey, string $cachePath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_read', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'cache_path' => $cachePath,
'operation' => 'read',
])
->withMetadata([
'recovery_strategy' => 'regenerate_cache',
'cache_type' => 'template',
]);
return self::fromContext(
"Template cache read failed for key '$cacheKey'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
$previous
);
}
public static function cacheInvalidationFailed(string $cacheKey, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_invalidation', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'operation' => 'invalidate',
]);
return self::fromContext(
"Template cache invalidation failed for key '$cacheKey'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
$previous
);
}
public static function invalidCacheFormat(string $cacheKey, string $expectedFormat, string $actualFormat, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_validation', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'expected_format' => $expectedFormat,
'actual_format' => $actualFormat,
])
->withMetadata([
'recovery_strategy' => 'regenerate_cache',
]);
return self::fromContext(
"Invalid template cache format for key '$cacheKey'. Expected '$expectedFormat', got '$actualFormat'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
$previous
);
}
}

View File

@@ -4,29 +4,118 @@ declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Modern Template Not Found Exception mit Framework-konformem Error Handling
*/
final class TemplateNotFoundException extends FrameworkException
{
public static function forTemplate(string $template, ?string $searchPath = null, ?array $searchPaths = null, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_resolution', 'TemplateEngine')
->withData([
'template' => $template,
'search_path' => $searchPath,
'search_paths' => $searchPaths ?? [],
'timestamp' => microtime(true),
])
->withMetadata([
'suggestions' => self::generateSuggestions($template, $searchPaths ?? []),
'template_type' => self::detectTemplateType($template),
]);
$pathInfo = $searchPath ? " (searched in: $searchPath)" : '';
return self::fromContext(
"Template \"$template\" nicht gefunden $pathInfo.",
$context,
ErrorCode::TPL_TEMPLATE_NOT_FOUND,
$previous
);
}
public static function withFallback(string $template, string $fallbackTemplate, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_fallback', 'TemplateEngine')
->withData([
'template' => $template,
'fallback_template' => $fallbackTemplate,
'recovery_strategy' => 'fallback_template',
]);
return self::fromContext(
"Template \"$template\" und Fallback \"$fallbackTemplate\" nicht gefunden.",
$context,
ErrorCode::TPL_TEMPLATE_NOT_FOUND,
$previous
);
}
/**
* Legacy constructor für Backward Compatibility
* @deprecated Use forTemplate() factory method instead
*/
public function __construct(
string $template,
string $file,
int $code = 0,
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('template_render', 'view_engine')
->withData([
'template' => $template,
'file' => $file,
]);
parent::__construct(
message: "Template \"$template\" nicht gefunden ($file).",
context: new ExceptionContext(
operation: 'template_render',
component: 'view_engine',
data: [
'template' => $template,
'file' => $file,
]
),
code: $code,
context: $context,
errorCode: ErrorCode::TPL_TEMPLATE_NOT_FOUND,
previous: $previous
);
}
/**
* Generiert Vorschläge für ähnliche Template-Namen
*/
private static function generateSuggestions(string $template, array $searchPaths): array
{
$suggestions = [];
// Simple Levenshtein-basierte Vorschläge (könnte erweitert werden)
foreach ($searchPaths as $path) {
if (is_dir($path)) {
$files = glob($path . '/*.{php,view.php}', GLOB_BRACE);
foreach ($files as $file) {
$basename = basename($file, '.php');
$basename = str_replace('.view', '', $basename);
if (levenshtein($template, $basename) <= 3) {
$suggestions[] = $basename;
}
}
}
}
return array_unique($suggestions);
}
/**
* Erkennt Template-Typ basierend auf Dateiname
*/
private static function detectTemplateType(string $template): string
{
if (str_contains($template, '/')) {
return 'path_based';
}
if (str_contains($template, '.')) {
return 'dotted_notation';
}
return 'simple_name';
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Template Processor Exception für Processor-spezifische Fehler
*/
final class TemplateProcessorException extends FrameworkException
{
public static function processorFailed(string $processorClass, string $template, string $operation, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_processing', $processorClass)
->withData([
'processor' => $processorClass,
'template' => $template,
'operation' => $operation,
])
->withMetadata([
'recovery_strategy' => 'skip_processor_or_fallback',
'processor_type' => self::getProcessorType($processorClass),
]);
return self::fromContext(
"Template processor '$processorClass' failed for template '$template' during '$operation'.",
$context,
ErrorCode::TPL_PROCESSOR_FAILED,
$previous
);
}
public static function assetNotFound(string $assetPath, string $manifestPath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('asset_injection', 'AssetInjector')
->withData([
'asset_path' => $assetPath,
'manifest_path' => $manifestPath,
'error_type' => 'asset_not_found',
])
->withMetadata([
'recovery_strategy' => 'use_development_fallback_or_skip',
]);
return self::fromContext(
"Asset '$assetPath' not found in manifest '$manifestPath'.",
$context,
ErrorCode::TPL_ASSET_NOT_FOUND,
$previous
);
}
public static function manifestNotFound(string $manifestPath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('manifest_loading', 'AssetInjector')
->withData([
'manifest_path' => $manifestPath,
'error_type' => 'manifest_not_found',
])
->withMetadata([
'recovery_strategy' => 'use_development_mode_or_disable_assets',
]);
return self::fromContext(
"Vite manifest not found: $manifestPath",
$context,
ErrorCode::TPL_ASSET_NOT_FOUND,
$previous
);
}
public static function contentLoadingFailed(string $path, string $reason, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('content_loading', 'TemplateContentLoader')
->withData([
'path' => $path,
'reason' => $reason,
'file_exists' => file_exists($path),
'is_readable' => is_readable($path),
])
->withMetadata([
'recovery_strategy' => 'check_permissions_or_fallback',
]);
return self::fromContext(
"Template content loading failed for '$path': $reason",
$context,
ErrorCode::TPL_CONTENT_LOADING_FAILED,
$previous
);
}
public static function invalidProcessorConfiguration(string $processorClass, string $issue, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('processor_configuration', 'TemplateEngine')
->withData([
'processor' => $processorClass,
'configuration_issue' => $issue,
]);
return self::fromContext(
"Invalid processor configuration for '$processorClass': $issue",
$context,
ErrorCode::TPL_PROCESSOR_FAILED,
$previous
);
}
public static function processorChainFailed(array $processorChain, string $failedProcessor, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('processor_chain', 'TemplateEngine')
->withData([
'processor_chain' => $processorChain,
'failed_processor' => $failedProcessor,
'chain_position' => array_search($failedProcessor, $processorChain),
])
->withMetadata([
'recovery_strategy' => 'skip_failed_processor_and_continue',
]);
return self::fromContext(
"Template processor chain failed at '$failedProcessor'.",
$context,
ErrorCode::TPL_PROCESSOR_FAILED,
$previous
);
}
/**
* Bestimmt den Processor-Typ basierend auf Klassenname
*/
private static function getProcessorType(string $processorClass): string
{
$className = basename(str_replace('\\', '/', $processorClass));
if (str_contains($className, 'Asset')) {
return 'asset';
}
if (str_contains($className, 'Form')) {
return 'form';
}
if (str_contains($className, 'Layout')) {
return 'layout';
}
if (str_contains($className, 'Component')) {
return 'component';
}
if (str_contains($className, 'Include')) {
return 'include';
}
return 'generic';
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Template Rendering Exception für Rendering-spezifische Fehler
*/
final class TemplateRenderingException extends FrameworkException
{
public static function renderingFailed(string $template, string $stage, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_rendering', 'TemplateProcessor')
->withData([
'template' => $template,
'stage' => $stage,
'error_type' => 'rendering_failed',
])
->withMetadata([
'recovery_strategy' => 'fallback_template_or_error_page',
'stage_description' => self::getStageDescription($stage),
]);
return self::fromContext(
"Template rendering failed for '$template' at stage '$stage'.",
$context,
ErrorCode::TPL_RENDERING_FAILED,
$previous
);
}
public static function invalidRendererOutput(string $template, string $expectedType, string $actualType, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_output_validation', 'TemplateProcessor')
->withData([
'template' => $template,
'expected_type' => $expectedType,
'actual_type' => $actualType,
]);
return self::fromContext(
"Template renderer must return '$expectedType', got '$actualType' for template '$template'.",
$context,
ErrorCode::TPL_RENDERING_FAILED,
$previous
);
}
public static function variableNotFound(string $template, string $variable, array $availableVariables = [], ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_variable_resolution', 'TemplateProcessor')
->withData([
'template' => $template,
'missing_variable' => $variable,
'available_variables' => array_keys($availableVariables),
])
->withMetadata([
'suggestions' => self::findSimilarVariables($variable, array_keys($availableVariables)),
]);
return self::fromContext(
"Template variable '$variable' not found in template '$template'.",
$context,
ErrorCode::TPL_VARIABLE_NOT_FOUND,
$previous
);
}
public static function syntaxError(string $template, string $error, int $line = 0, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_parsing', 'TemplateProcessor')
->withData([
'template' => $template,
'syntax_error' => $error,
'line' => $line,
])
->withMetadata([
'error_category' => 'syntax',
]);
$lineInfo = $line > 0 ? " at line $line" : '';
return self::fromContext(
"Template syntax error in '$template'$lineInfo: $error",
$context,
ErrorCode::TPL_SYNTAX_ERROR,
$previous
);
}
public static function compilationFailed(string $template, string $compiler, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_compilation', $compiler)
->withData([
'template' => $template,
'compiler' => $compiler,
]);
return self::fromContext(
"Template compilation failed for '$template' using compiler '$compiler'.",
$context,
ErrorCode::TPL_COMPILATION_FAILED,
$previous
);
}
/**
* Beschreibung der Rendering-Stufen
*/
private static function getStageDescription(string $stage): string
{
return match($stage) {
'loading' => 'Template file loading',
'parsing' => 'Template syntax parsing',
'compiling' => 'Template compilation',
'processing' => 'Template variable processing',
'rendering' => 'Final template rendering',
default => "Unknown stage: $stage"
};
}
/**
* Findet ähnliche Variablen-Namen mit Levenshtein-Distanz
*/
private static function findSimilarVariables(string $missing, array $available): array
{
$suggestions = [];
foreach ($available as $variable) {
if (levenshtein($missing, $variable) <= 2) {
$suggestions[] = $variable;
}
}
return $suggestions;
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\Http\Session\FormIdGenerator;
use App\Framework\View\ValueObjects\FormElement;
use App\Framework\View\ValueObjects\FormId;
use App\Framework\View\ValueObjects\HtmlElement;
final readonly class FormBuilder
{
/**
* @var HtmlElement[]
*/
private array $elements;
public function __construct(
private FormElement $form,
private FormIdGenerator $formIdGenerator,
array $elements = []
) {
$this->elements = $elements;
}
public static function create(
string $action = '',
string $method = 'post',
?FormIdGenerator $formIdGenerator = null
): self {
$generator = $formIdGenerator ?? new FormIdGenerator();
$form = FormElement::form($action, $method);
return new self($form, $generator);
}
public function withHtmlId(string $htmlId): self
{
$newForm = new FormElement(
$this->form->tag,
$this->form->attributes->withId($htmlId),
$this->form->content
);
return new self($newForm, $this->formIdGenerator, $this->elements);
}
public function withAction(string $action): self
{
$newForm = new FormElement(
$this->form->tag,
$this->form->attributes->withAction($action),
$this->form->content
);
return new self($newForm, $this->formIdGenerator, $this->elements);
}
public function withMethod(string $method): self
{
$newForm = new FormElement(
$this->form->tag,
$this->form->attributes->withMethod($method),
$this->form->content
);
return new self($newForm, $this->formIdGenerator, $this->elements);
}
public function withClass(string $class): self
{
$newForm = $this->form->withClass($class);
return new self($newForm, $this->formIdGenerator, $this->elements);
}
public function addElement(HtmlElement $element): self
{
return new self($this->form, $this->formIdGenerator, [...$this->elements, $element]);
}
public function addTextInput(string $name, string $value = '', string $label = ''): self
{
$elements = [];
if ($label) {
$elements[] = FormElement::label($label, $name);
}
$elements[] = FormElement::textInput($name, $value)->withId($name);
return new self($this->form, $this->formIdGenerator, [...$this->elements, ...$elements]);
}
public function addEmailInput(string $name, string $value = '', string $label = ''): self
{
$elements = [];
if ($label) {
$elements[] = FormElement::label($label, $name);
}
$elements[] = FormElement::emailInput($name, $value)
->withId($name)
->withRequired();
return new self($this->form, $this->formIdGenerator, [...$this->elements, ...$elements]);
}
public function addPasswordInput(string $name, string $label = ''): self
{
$elements = [];
if ($label) {
$elements[] = FormElement::label($label, $name);
}
$elements[] = FormElement::passwordInput($name)
->withId($name)
->withRequired();
return new self($this->form, $this->formIdGenerator, [...$this->elements, ...$elements]);
}
public function addFileInput(string $name, string $label = '', bool $required = false): self
{
$elements = [];
if ($label) {
$elements[] = FormElement::label($label, $name);
}
$input = FormElement::fileInput($name)->withId($name);
if ($required) {
$input = $input->withRequired();
}
$elements[] = $input;
return new self($this->form, $this->formIdGenerator, [...$this->elements, ...$elements]);
}
public function addTextarea(string $name, string $content = '', string $label = ''): self
{
$elements = [];
if ($label) {
$elements[] = FormElement::label($label, $name);
}
$elements[] = FormElement::textarea($name, $content)->withId($name);
return new self($this->form, $this->formIdGenerator, [...$this->elements, ...$elements]);
}
public function addSubmitButton(string $text = 'Submit'): self
{
return $this->addElement(FormElement::submitButton($text));
}
public function addHiddenInput(string $name, string $value): self
{
return $this->addElement(FormElement::hiddenInput($name, $value));
}
// CSRF Token und FormId werden automatisch vom FormProcessor hinzugefügt
// Diese Methoden sind nicht mehr nötig, da sie durch das Template Processing automatisch eingefügt werden
public function build(): string
{
$formContent = '';
foreach ($this->elements as $element) {
$formContent .= (string) $element . "\n";
}
$formWithContent = new FormElement(
$this->form->tag,
$this->form->attributes,
$formContent
);
return (string) $formWithContent;
}
public function __toString(): string
{
return $this->build();
}
}

View File

@@ -4,18 +4,29 @@ declare(strict_types=1);
namespace App\Framework\View\Loading;
use App\Framework\View\Exceptions\TemplateProcessorException;
final readonly class TemplateContentLoader
{
public function load(string $path): string
{
if (! file_exists($path)) {
throw new \RuntimeException("Template file not found: {$path}");
throw TemplateProcessorException::contentLoadingFailed(
$path,
'Template file not found'
);
}
$content = @file_get_contents($path);
if ($content === false) {
throw new \RuntimeException("Template could not be read: {$path}");
$error = error_get_last();
$reason = $error ? $error['message'] : 'Unknown read error';
throw TemplateProcessorException::contentLoadingFailed(
$path,
"Template could not be read: $reason"
);
}
// HTML-Entities dekodieren, damit -> nicht als &gt; erscheint

View File

@@ -33,6 +33,7 @@ final readonly class TemplateLoader
private array $templates = [],
private string $templatePath = '/src/Framework/View/templates',
private bool $useMtimeInvalidation = true,
private bool $cacheEnabled = false, // Caching disabled for debugging
) {
$this->pathResolver = $this->createPathResolver();
$this->contentLoader = new TemplateContentLoader();
@@ -66,6 +67,9 @@ final readonly class TemplateLoader
public function load(string $template, ?string $controllerClass = null, ?RenderContext $context = null): string
{
error_log("TemplateLoader::load ENTRY - Template: $template, ControllerClass: " . ($controllerClass ?? 'null'));
error_log("TemplateLoader::load - Cache enabled: " . ($this->cacheEnabled ? 'YES' : 'NO'));
$resolvedPath = null;
$mtimeSegment = '';
@@ -77,19 +81,34 @@ final readonly class TemplateLoader
$cacheKey = $this->generateCacheKey($template, $controllerClass, $mtimeSegment);
// Try cache first
$cached = $this->cache->get($cacheKey)->getItem($cacheKey);
if ($cached->isHit && is_string($cached->value)) {
return $cached->value;
// Try cache first ONLY if caching is enabled
if ($this->cacheEnabled) {
$cached = $this->cache->get($cacheKey)->getItem($cacheKey);
if ($cached->isHit && is_string($cached->value)) {
error_log("TemplateLoader::load CACHE HIT - Template: $template, Cached content length: " . strlen($cached->value));
error_log("TemplateLoader::load CACHE HIT - Cached content starts with: " . substr($cached->value, 0, 100));
error_log("TemplateLoader::load CACHE HIT - Cached content contains '<layout': " . (strpos($cached->value, '<layout') !== false ? 'YES' : 'NO'));
return $cached->value;
}
} else {
error_log("TemplateLoader::load - Caching disabled, loading from filesystem");
}
// Load from filesystem
$path = $resolvedPath ?? $this->pathResolver->resolve($template, $controllerClass);
$content = $this->contentLoader->load($path);
// Cache for future use (24 hours TTL)
$ttl = Duration::fromSeconds(86400);
$this->cache->set(CacheItem::forSet($cacheKey, $content, $ttl));
// Cache for future use (24 hours TTL) ONLY if caching is enabled
if ($this->cacheEnabled) {
$ttl = Duration::fromSeconds(86400);
$this->cache->set(CacheItem::forSet($cacheKey, $content, $ttl));
error_log("TemplateLoader::load - Content cached for template: $template");
}
error_log("TemplateLoader::load RETURN - Template: $template, Content length: " . strlen($content));
error_log("TemplateLoader::load RETURN - Content starts with: " . substr($content, 0, 100));
error_log("TemplateLoader::load RETURN - Content contains '<layout': " . (strpos($content, '<layout') !== false ? 'YES' : 'NO'));
return $content;
}

View File

@@ -21,15 +21,33 @@ final readonly class DomProcessingPipeline
public function process(RenderContext $context, string $html): DomWrapper
{
file_put_contents('/tmp/debug.log', "DomProcessingPipeline::process ENTRY - Template: " . $context->template . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "DomProcessingPipeline::process ENTRY - HTML length: " . strlen($html) . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "DomProcessingPipeline::process ENTRY - HTML starts with: " . substr($html, 0, 200) . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "DomProcessingPipeline::process ENTRY - HTML contains '<component': " . (strpos($html, '<component') !== false ? 'YES' : 'NO') . "\n", FILE_APPEND | LOCK_EX);
$parser = new DomTemplateParser();
$dom = $parser->parseToWrapper($html);
foreach ($this->processors as $processorClass) {
/** @var DomProcessor $processor */
$processor = $this->resolver->resolve($processorClass);
$dom = $processor->process($dom, $context);
try {
file_put_contents('/tmp/debug.log', "DomProcessingPipeline: Processing with " . $processorClass . "\n", FILE_APPEND | LOCK_EX);
/** @var DomProcessor $processor */
$processor = $this->resolver->resolve($processorClass);
file_put_contents('/tmp/debug.log', "DomProcessingPipeline: Successfully resolved " . $processorClass . "\n", FILE_APPEND | LOCK_EX);
$dom = $processor->process($dom, $context);
file_put_contents('/tmp/debug.log', "DomProcessingPipeline: Completed processing with " . $processorClass . "\n", FILE_APPEND | LOCK_EX);
} catch (\Throwable $e) {
file_put_contents('/tmp/debug.log', "DomProcessingPipeline: ERROR processing " . $processorClass . ": " . $e->getMessage() . "\n", FILE_APPEND | LOCK_EX);
file_put_contents('/tmp/debug.log', "DomProcessingPipeline: Stack trace: " . $e->getTraceAsString() . "\n", FILE_APPEND | LOCK_EX);
throw $e;
}
}
file_put_contents('/tmp/debug.log', "DomProcessingPipeline: ALL PROCESSORS COMPLETED SUCCESSFULLY\n", FILE_APPEND | LOCK_EX);
return $dom;
}
}

View File

@@ -8,6 +8,7 @@ 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
@@ -25,9 +26,8 @@ final class AssetInjector implements DomProcessor
#$manifestPath = dirname(__DIR__, 3) . '../public/.vite/manifest.json';
if (! is_file($manifestPath)) {
throw new \RuntimeException("Vite manifest not found: $manifestPath");
throw TemplateProcessorException::manifestNotFound($manifestPath);
}
$json = file_get_contents($manifestPath);
@@ -36,36 +36,43 @@ final class AssetInjector implements DomProcessor
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';
// CSS-Key, wie im Manifest unter "resources/css/styles.css"
$cssKey = 'resources/css/styles.css';
#$head = $dom->getElementsByTagName('head')->item(0);
$head = $dom->document->head;
$insertParent = $head ?: $dom->document->getElementsByTagName('body')->item(0) ?: $dom->document->documentElement;
// Debug: Log what we have in manifest
error_log("AssetInjector: Processing with manifest: " . json_encode($this->manifest));
// --- CSS, wie von Vite empfohlen: Feld "css" beim js-Eintrag! ---
// 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, '/'));
/*$link = $dom->document->createElement('link');
$link->setAttribute('rel', 'stylesheet');
$link->setAttribute('href', '/' . ltrim($cssFile, '/'));
$insertParent->appendChild($link);*/
}
}
// --- 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'], '/'));
/*$script = $dom->document->createElement('script');
$script->setAttribute('src', '/' . ltrim($this->manifest[$jsKey]['file'], '/'));
$script->setAttribute('type', 'module');
$insertParent->appendChild($script);*/
}
return $dom;

View File

@@ -4,6 +4,8 @@ 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\ComponentRenderer;
use App\Framework\View\DomComponentService;
@@ -18,7 +20,8 @@ final readonly class ComponentProcessor implements DomProcessor
{
public function __construct(
private DomComponentService $componentService,
private ComponentRenderer $renderer
private ComponentRenderer $renderer,
private Container $container
) {
}
@@ -30,17 +33,25 @@ final readonly class ComponentProcessor implements DomProcessor
/** @var HTMLElement $component */
$name = $component->getAttribute('name');
if (! $name) {
if (!$name) {
return;
}
$attributes = $this->extractAttributes($component);
$componentHtml = $this->renderer->render($name, array_merge($context->data, $attributes));
$this->componentService->replaceComponent($dom, $component, $componentHtml);
#$dom->replaceElementWithHtml($component, $componentHtml);
try {
// Pre-process attributes for <for> loops before passing to component renderer
try {
$processedAttributes = $this->processAttributeContent($attributes, $context);
} catch (\Throwable $e) {
$processedAttributes = $attributes;
}
$componentHtml = $this->renderer->render($name, array_merge($context->data, $processedAttributes));
$this->componentService->replaceComponent($dom, $component, $componentHtml);
} catch (\Throwable $e) {
// Component rendering failed - could log error here
}
});
return $dom;
@@ -49,12 +60,78 @@ final readonly class ComponentProcessor implements DomProcessor
private function extractAttributes(HTMLElement $component): array
{
$attributes = [];
// Extract HTML attributes first
foreach ($component->attributes as $attr) {
if ($attr->nodeName !== 'name') {
$attributes[$attr->nodeName] = $attr->nodeValue;
}
}
// Extract <attribute> child elements and their innerHTML
$attributeElements = $component->getElementsByTagName('attribute');
foreach ($attributeElements as $attrElement) {
/** @var HTMLElement $attrElement */
$name = $attrElement->getAttribute('name');
if ($name) {
// Get the innerHTML content of the <attribute> element
$attributes[$name] = $attrElement->innerHTML;
}
}
return $attributes;
}
/**
* Processes attribute content for <for> loops before passing to component renderer
*/
private function processAttributeContent(array $attributes, RenderContext $context): array
{
$processedAttributes = [];
foreach ($attributes as $name => $content) {
if (is_string($content) && strpos($content, '<for') !== false) {
// Create a minimal HTML wrapper for parsing
$wrappedContent = "<div>$content</div>";
try {
// Parse the content as DOM
$parser = new DomTemplateParser();
$domWrapper = $parser->parseToWrapper($wrappedContent);
// Create a ForProcessor and process the content
$forProcessor = new ForProcessor($this->container);
$processedDom = $forProcessor->process($domWrapper, $context);
// Extract the processed content (remove the wrapper div)
$processedHtml = $processedDom->document->saveHTML();
// Decode HTML entities that were created during DOM parsing
$processedHtml = html_entity_decode($processedHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Additional decoding for double-encoded entities
$processedHtml = html_entity_decode($processedHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Force decode any remaining HTML entities for RawHtml content
$processedHtml = html_entity_decode($processedHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Direct string replacement for stubborn HTML entities
$processedHtml = str_replace(['&lt;', '&gt;', '&quot;', '&amp;'], ['<', '>', '"', '&'], $processedHtml);
// Remove the wrapper div tags
$processedContent = preg_replace('/^.*?<div>(.*)<\/div>.*$/s', '$1', $processedHtml);
$processedAttributes[$name] = $processedContent;
} catch (\Throwable $e) {
// Fallback to original content if processing fails
$processedAttributes[$name] = $content;
}
} else {
// No <for> loops, use content as-is
$processedAttributes[$name] = $content;
}
}
return $processedAttributes;
}
}

View File

@@ -164,8 +164,8 @@ final class CsrfTokenProcessor implements StringProcessor
{
// Priorität: Context-Daten > Generierter Token
return $context->data['csrf_token'] ??
$context->data['_token'] ??
$this->generateFreshCsrfToken();
$context->data['_token'] ??
$this->generateFreshCsrfToken();
}
/**

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\DI\Container;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
final readonly class ForDomProcessor implements DomProcessor
{
public function __construct(
private Container $container,
private ForStringProcessor $stringProcessor
) {
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
error_log("🔧 ForDomProcessor: Processing DOM for final <for> tag cleanup");
// Get the complete HTML content as string
$htmlContent = $dom->document->saveHTML();
error_log("🔧 ForDomProcessor: Content contains '<for': " . (strpos($htmlContent, '<for') !== false ? 'YES' : 'NO'));
error_log("🔧 ForDomProcessor: Content contains 'health_checks': " . (strpos($htmlContent, 'health_checks') !== false ? 'YES' : 'NO'));
// If we have <for> tags, process the entire HTML string before DOM parsing touches it
if (strpos($htmlContent, '<for') !== false) {
error_log("🔧 ForDomProcessor: Found <for> tags, processing entire HTML string");
// Process the entire HTML content with string processor
$processedHtmlContent = $this->stringProcessor->process($htmlContent, $context);
error_log("🔧 ForDomProcessor: HTML processing complete, result contains '<for': " . (strpos($processedHtmlContent, '<for') !== false ? 'YES' : 'NO'));
// Parse the processed HTML back into DOM
if ($processedHtmlContent !== $htmlContent) {
error_log("🔧 ForDomProcessor: HTML was modified, updating body content");
// Find the body and replace its content with the processed content
$body = $dom->document->querySelector('body');
if ($body) {
// Extract just the body content from the processed HTML
if (preg_match('/<body[^>]*>(.*?)<\/body>/s', $processedHtmlContent, $matches)) {
$body->innerHTML = $matches[1];
error_log("🔧 ForDomProcessor: Body content updated with processed HTML");
}
}
}
}
return $dom;
}
}

View File

@@ -8,67 +8,106 @@ use App\Framework\DI\Container;
use App\Framework\Meta\MetaData;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RawHtml;
use App\Framework\View\RenderContext;
use App\Framework\View\TemplateProcessor;
final class ForProcessor implements DomProcessor
{
public function __construct(
private Container $container,
private ?TemplateProcessor $templateProcessor = null,
) {
// Falls kein TemplateProcessor übergeben wird, erstellen wir einen mit den Standard-Prozessoren
if ($this->templateProcessor === null) {
#$this->templateProcessor = new TemplateProcessor([],[PlaceholderReplacer::class], $this->container);
#$this->templateProcessor->register(PlaceholderReplacer::class);
$this->templateProcessor = $this->container->get(TemplateProcessor::class);
}
}
) {}
/**
* @inheritDoc
*/
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
foreach ($dom->document->querySelectorAll('for[var][in]') as $node) {
$forNodes = $dom->document->querySelectorAll('for[var][in]');
foreach ($forNodes as $node) {
$var = $node->getAttribute('var');
$in = $node->getAttribute('in');
$output = '';
// Handle nested property paths (e.g., "redis.key_sample")
// Resolve items from context data or model
$items = $this->resolveValue($context->data, $in);
// Fallback to model if not found in data
if ($items === null && isset($context->model)) {
$items = $this->resolveValue(['model' => $context->model], 'model.' . $in);
if (str_starts_with($in, 'model.')) {
$items = $this->resolveValue(['model' => $context->model], $in);
} else {
$items = $this->resolveValue(['model' => $context->model], 'model.' . $in);
}
}
if (is_iterable($items)) {
foreach ($items as $item) {
$clone = $node->cloneNode(true);
// Neuen Kontext für die Schleifenvariable erstellen
// Create loop context with loop variable
$loopContext = new RenderContext(
template: $context->template,
metaData: new MetaData('', ''),
data: array_merge($context->data, [$var => $item]),
#model: $context->model,
controllerClass: $context->controllerClass
);
// Den Inhalt der Schleife mit den bestehenden Prozessoren verarbeiten
// Get innerHTML from cloned node
$innerHTML = $clone->innerHTML;
$processedContent = $this->templateProcessor->render($loopContext, $innerHTML, true);
// Handle case where DOM parser treats <for> as self-closing
if (trim($innerHTML) === '') {
$innerHTML = $this->collectSiblingContent($node, $dom);
}
// Replace loop variable placeholders
$innerHTML = $this->replaceLoopVariables($innerHTML, $var, $item);
// Process placeholders in loop content
$placeholderReplacer = $this->container->get(PlaceholderReplacer::class);
$processedContent = $placeholderReplacer->process($innerHTML, $loopContext);
// Handle nested <for> tags recursively
if (str_contains($processedContent, '<for ')) {
try {
$tempWrapper = DomWrapper::fromString($processedContent);
$this->process($tempWrapper, $loopContext);
$processedContent = $tempWrapper->toHtml(true);
} catch (\Exception $e) {
// Continue with unprocessed content on error
}
}
$output .= $processedContent;
}
}
$replacement = $dom->document->createDocumentFragment();
@$replacement->appendXML($output);
$node->parentNode?->replaceChild($replacement, $node);
// Replace for node with processed output
if (!empty($output)) {
try {
$replacement = $dom->document->createDocumentFragment();
@$replacement->appendXML($output);
$node->parentNode?->replaceChild($replacement, $node);
} catch (\Exception $e) {
// Fallback: Use innerHTML approach
$tempDiv = $dom->document->createElement('div');
$tempDiv->innerHTML = $output;
$parent = $node->parentNode;
$nextSibling = $node->nextSibling;
$parent->removeChild($node);
while ($tempDiv->firstChild) {
if ($nextSibling) {
$parent->insertBefore($tempDiv->firstChild, $nextSibling);
} else {
$parent->appendChild($tempDiv->firstChild);
}
}
}
} else {
// Remove empty for node
$node->parentNode?->removeChild($node);
}
}
return $dom;
@@ -85,8 +124,20 @@ final class ForProcessor implements DomProcessor
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value) && isset($value->$key)) {
$value = $value->$key;
} elseif (is_object($value)) {
// Try property access first
if (isset($value->$key)) {
$value = $value->$key;
} elseif (method_exists($value, $key)) {
// Try method call
$value = $value->$key();
} elseif (method_exists($value, 'get' . ucfirst($key))) {
// Try getter method
$getterMethod = 'get' . ucfirst($key);
$value = $value->$getterMethod();
} else {
return null;
}
} else {
return null;
}
@@ -94,4 +145,91 @@ final class ForProcessor implements DomProcessor
return $value;
}
/**
* Replaces loop variable placeholders in the HTML content
*/
private function replaceLoopVariables(string $html, string $varName, mixed $item): string
{
$pattern = '/{{\\s*' . preg_quote($varName, '/') . '\\.([\\w]+)\\s*}}/';
return preg_replace_callback(
$pattern,
function ($matches) use ($item) {
$property = $matches[1];
if (is_array($item) && array_key_exists($property, $item)) {
$value = $item[$property];
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value instanceof RawHtml) {
return $value->content;
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} elseif (is_object($item) && isset($item->$property)) {
$value = $item->$property;
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value instanceof RawHtml) {
return $value->content;
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// Return placeholder unchanged if property not found
return $matches[0];
},
$html
);
}
/**
* Collects content from sibling nodes when <for> is treated as self-closing
*/
private function collectSiblingContent($forNode, DomWrapper $dom): string
{
$content = '';
$currentNode = $forNode->nextSibling;
while ($currentNode !== null) {
if ($currentNode->nodeType === XML_ELEMENT_NODE) {
// Check for loop content elements (TR for tables, DIV for other structures)
if ($currentNode->tagName === 'TR') {
$content .= $dom->document->saveHTML($currentNode);
$nextNode = $currentNode->nextSibling;
$currentNode->parentNode->removeChild($currentNode);
$currentNode = $nextNode;
break; // One TR per iteration
} elseif ($currentNode->tagName === 'TABLE') {
// Look for template TR inside table
$tableRows = $currentNode->querySelectorAll('tr');
foreach ($tableRows as $row) {
$rowHtml = $dom->document->saveHTML($row);
// Find row with placeholders (template row)
if (str_contains($rowHtml, '{{')) {
$content = $rowHtml;
break 2;
}
}
$currentNode = $currentNode->nextSibling;
} else {
$currentNode = $currentNode->nextSibling;
}
} else {
$currentNode = $currentNode->nextSibling;
}
}
return $content;
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\DI\Container;
use App\Framework\Template\Processing\StringProcessor;
use App\Framework\View\RenderContext;
final readonly class ForStringProcessor implements StringProcessor
{
public function __construct(
private Container $container
) {
}
public function process(string $content, RenderContext $context): string
{
error_log("🔧🔧🔧 ForStringProcessor::process() CALLED - Template: " . $context->template);
error_log("🔧 ForStringProcessor: Processing content, looking for <for> tags");
error_log("🔧 ForStringProcessor: Content contains '<for': " . (strpos($content, '<for') !== false ? 'YES' : 'NO'));
error_log("🔧 ForStringProcessor: Available data keys: " . implode(', ', array_keys($context->data)));
// Process nested <for> loops iteratively from innermost to outermost
$result = $content;
$maxIterations = 10; // Prevent infinite loops
$iteration = 0;
while (strpos($result, '<for') !== false && $iteration < $maxIterations) {
$iteration++;
error_log("🔧 ForStringProcessor: Processing iteration $iteration");
$previousResult = $result;
$result = $this->processForLoops($result, $context);
// If no changes were made, break to avoid infinite loop
if ($result === $previousResult) {
error_log("🔧 ForStringProcessor: No changes in iteration $iteration, stopping");
break;
}
}
error_log("🔧 ForStringProcessor: Processing complete after $iteration iterations, result contains '<for': " . (strpos($result, '<for') !== false ? 'YES' : 'NO'));
return $result;
}
/**
* Process one level of <for> loops (innermost first to handle nesting)
*/
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';
return preg_replace_callback(
$pattern,
function ($matches) use ($context) {
$varName = $matches[1];
$dataKey = $matches[2];
$template = $matches[3];
error_log("🔧 ForStringProcessor: Processing loop - var='$varName', in='$dataKey'");
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;
},
$content
);
}
/**
* Resolves nested property paths like "redis.key_sample"
*/
private function resolveValue(array $data, string $expr): mixed
{
$keys = explode('.', $expr);
$value = $data;
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value)) {
// Try property access first
if (isset($value->$key)) {
$value = $value->$key;
} elseif (method_exists($value, $key)) {
// Try method call if property doesn't exist
$value = $value->$key();
} elseif (method_exists($value, 'get' . ucfirst($key))) {
// Try getter method
$getterMethod = 'get' . ucfirst($key);
$value = $value->$getterMethod();
} else {
return null;
}
} else {
return null;
}
}
return $value;
}
/**
* Replaces loop variable placeholders in the template content
*/
private function replaceLoopVariables(string $template, string $varName, mixed $item): string
{
error_log("🔧 ForStringProcessor: replaceLoopVariables called");
error_log("🔧 ForStringProcessor: varName='$varName', item type=" . gettype($item));
if (is_array($item)) {
error_log("🔧 ForStringProcessor: item keys: " . implode(', ', array_keys($item)));
}
error_log("🔧 ForStringProcessor: template snippet: " . substr($template, 0, 100));
// Handle simple loop variable placeholders like {{ item.property }}
$pattern = '/\{\{\s*' . preg_quote($varName, '/') . '\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/';
error_log("🔧 ForStringProcessor: using pattern: " . $pattern);
$result = preg_replace_callback(
$pattern,
function ($matches) use ($item, $varName) {
$property = $matches[1];
error_log("🔧 ForStringProcessor: trying to replace property '$property' in placeholder '{$matches[0]}'");
if (is_array($item) && array_key_exists($property, $item)) {
$value = $item[$property];
error_log("🔧 ForStringProcessor: found property '$property' with value '$value'");
// Handle boolean values properly
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} elseif (is_object($item) && isset($item->$property)) {
$value = $item->$property;
error_log("🔧 ForStringProcessor: found object property '$property' with value '$value'");
// Handle boolean values properly
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
error_log("🔧 ForStringProcessor: property '$property' not found, returning unchanged placeholder");
// Return placeholder unchanged if property not found
return $matches[0];
},
$template
);
error_log("🔧 ForStringProcessor: replaceLoopVariables result snippet: " . substr($result, 0, 100));
return $result;
}
}

View File

@@ -9,6 +9,7 @@ use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomFormService;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
use App\Framework\View\ValueObjects\FormId;
use Dom\Element;
final readonly class FormProcessor implements DomProcessor
@@ -85,12 +86,12 @@ final readonly class FormProcessor implements DomProcessor
$form->insertBefore($csrf, $form->firstChild);
}
private function addFormId(DomWrapper $dom, Element $form, string $formId): void
private function addFormId(DomWrapper $dom, Element $form, FormId $formId): void
{
// Check if form ID already exists
$existing = $form->querySelector('input[name="_form_id"]');
if ($existing) {
$existing->setAttribute('value', $formId);
$existing->setAttribute('value', (string) $formId);
return;
}
@@ -98,7 +99,7 @@ final readonly class FormProcessor implements DomProcessor
$formIdField = $dom->document->createElement('input');
$formIdField->setAttribute('name', '_form_id');
$formIdField->setAttribute('type', 'hidden');
$formIdField->setAttribute('value', $formId);
$formIdField->setAttribute('value', (string) $formId);
$form->insertBefore($formIdField, $form->firstChild);
}

View File

@@ -0,0 +1,242 @@
<?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

@@ -23,14 +23,20 @@ final readonly class HoneypotProcessor implements DomProcessor
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;
}
@@ -64,8 +70,14 @@ final readonly class HoneypotProcessor implements DomProcessor
$nameField->setAttribute('name', '_honeypot_name');
$nameField->setAttribute('value', $honeypotName);
$form->insertBefore($container, $form->firstChild);
$form->insertBefore($nameField, $form->firstChild);
$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
@@ -75,6 +87,11 @@ final readonly class HoneypotProcessor implements DomProcessor
$timeField->setAttribute('name', '_form_start_time');
$timeField->setAttribute('value', (string)time());
$form->insertBefore($timeField, $form->firstChild);
$firstChild = $form->firstChild;
if ($firstChild !== null) {
$form->insertBefore($timeField, $firstChild);
} else {
$form->appendChild($timeField);
}
}
}

View File

@@ -12,22 +12,166 @@ final readonly class IfProcessor implements DomProcessor
{
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
$dom->getELementsByAttribute('if')->forEach(function ($node) use ($context) {
$condition = $node->getAttribute('if');
// Handle both 'if' and 'condition' attributes
$this->processIfAttribute($dom, $context, 'if');
$this->processIfAttribute($dom, $context, 'condition');
// Handle nested property paths (e.g., "performance.opcacheMemoryUsage")
$value = $this->resolveValue($context->data, $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 (! $this->isTruthy($value)) {
if (! $result) {
$node->parentNode?->removeChild($node);
} else {
// Entferne Attribut bei Erfolg
$node->removeAttribute('if');
$node->removeAttribute($attributeName);
}
});
}
return $dom;
/**
* Evaluates a condition expression with support for operators
*/
private function evaluateCondition(array $data, string $condition): bool
{
$condition = trim($condition);
// Handle logical operators (&&, ||)
if (str_contains($condition, '&&')) {
$parts = array_map('trim', explode('&&', $condition));
foreach ($parts as $part) {
if (! $this->evaluateCondition($data, $part)) {
return false;
}
}
return true;
}
if (str_contains($condition, '||')) {
$parts = array_map('trim', explode('||', $condition));
foreach ($parts as $part) {
if ($this->evaluateCondition($data, $part)) {
return true;
}
}
return false;
}
// Handle negation (!)
if (str_starts_with($condition, '!')) {
$negatedCondition = trim(substr($condition, 1));
return ! $this->evaluateCondition($data, $negatedCondition);
}
// Handle comparison operators
foreach (['!=', '==', '>=', '<=', '>', '<'] as $operator) {
if (str_contains($condition, $operator)) {
[$left, $right] = array_map('trim', explode($operator, $condition, 2));
$leftValue = $this->parseValue($data, $left);
$rightValue = $this->parseValue($data, $right);
return match ($operator) {
'!=' => $leftValue != $rightValue,
'==' => $leftValue == $rightValue,
'>=' => $leftValue >= $rightValue,
'<=' => $leftValue <= $rightValue,
'>' => $leftValue > $rightValue,
'<' => $leftValue < $rightValue,
};
}
}
// Simple property evaluation (fallback to original behavior)
$value = $this->resolveValue($data, $condition);
return $this->isTruthy($value);
}
/**
* Parse a value from expression (can be a property path, string literal, or number)
*/
private function parseValue(array $data, string $expr): mixed
{
$expr = trim($expr);
// String literal (quoted)
if ((str_starts_with($expr, '"') && str_ends_with($expr, '"')) ||
(str_starts_with($expr, "'") && str_ends_with($expr, "'"))) {
return substr($expr, 1, -1);
}
// Number literal
if (is_numeric($expr)) {
return str_contains($expr, '.') ? (float) $expr : (int) $expr;
}
// Boolean literals
if ($expr === 'true') {
return true;
}
if ($expr === 'false') {
return false;
}
if ($expr === 'null') {
return null;
}
// Property path (including .length and method calls)
return $this->resolveComplexValue($data, $expr);
}
/**
* Resolves complex expressions including method calls and array properties
*/
private function resolveComplexValue(array $data, string $expr): mixed
{
// Handle method calls like isEmpty()
if (str_contains($expr, '()')) {
$methodPos = strpos($expr, '()');
$basePath = substr($expr, 0, $methodPos);
$methodName = substr($basePath, strrpos($basePath, '.') + 1);
$objectPath = substr($basePath, 0, strrpos($basePath, '.'));
$object = $this->resolveValue($data, $objectPath);
if (is_object($object) && method_exists($object, $methodName)) {
return $object->$methodName();
}
return null;
}
// Handle .length property for arrays and collections
if (str_ends_with($expr, '.length')) {
$basePath = substr($expr, 0, -7); // Remove '.length'
$value = $this->resolveValue($data, $basePath);
if (is_array($value)) {
return count($value);
}
if (is_object($value) && method_exists($value, 'count')) {
return $value->count();
}
if (is_countable($value)) {
return count($value);
}
return 0;
}
// Standard property path resolution
return $this->resolveValue($data, $expr);
}
/**

View File

@@ -4,6 +4,7 @@ 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;
@@ -15,51 +16,122 @@ 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;
}
return $this->applyLayout($layoutTag, $dom);
error_log("LayoutTagProcessor: Layout tag found, processing");
return $this->applyLayout($layoutTag, $dom, $context);
}
private function findLayoutTag(DomWrapper $dom): ?Element
{
$dom = $dom->document;
$layoutTags = $dom->querySelectorAll('layout[src]');
// 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): DomWrapper
private function applyLayout(Element $layoutTag, DomWrapper $dom, RenderContext $context): DomWrapper
{
$layoutFile = $layoutTag->getAttribute('src');
// Support both 'name' and 'src' attributes
$layoutFile = $layoutTag->getAttribute('name') ?: $layoutTag->getAttribute('src');
$layoutPath = $this->loader->getTemplatePath($layoutFile);
$layoutDom = $this->parser->parseFileToWrapper($layoutPath);
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
}
$slot->innerHTML = $layoutTag->innerHTML;
// 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);

View File

@@ -13,33 +13,59 @@ final readonly class MetaManipulator implements DomProcessor
{
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
$description = $context->metaData->description;
$metaData = $context->metaData;
// Sichere head-Element-Erkennung
$head = $dom->getElementsByTagName('head')->first();
if (! $head) {
error_log("MetaManipulator: No head element found");
$dom->document->head->querySelector('meta[name="description"]')?->setAttribute('content', $description);
if ($dom->document->head->getElementsByTagName('title')->item(0)) {
$dom->document->head->getElementsByTagName('title')->item(0)->textContent = $context->metaData->title . " | Michael Schiemer";
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);
}
}
match($context->metaData->openGraph->type) {
OpenGraphType::WEBSITE => $dom->document->head->querySelector('meta[property="og:type"]')?->setAttribute('content', 'website'),
default => throw new \Exception('Unexpected match value'),
// 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',
};
foreach ($dom->document->querySelectorAll('meta[name][content]') as $meta) {
$name = $meta->getAttribute('name');
$content = $meta->getAttribute('content');
#debug($name);
// Wenn Variable bereits im Context gesetzt ist, nicht überschreiben
#if (!array_key_exists($name, $context->data)) {
# $context->data[$name] = $content;
#}
$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

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\View\Processors;
use App\Framework\Config\AppConfig;
use App\Framework\DI\Container;
use App\Framework\Template\Processing\StringProcessor;
use App\Framework\View\Functions\ImageSlotFunction;
@@ -178,6 +179,33 @@ final class PlaceholderReplacer implements StringProcessor
return $value->content;
}
// HtmlElement-Objekte nicht escapen (Framework Components)
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
return (string) $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);
}
// Zusätzlicher Schutz gegen Array-to-String Conversion
if (is_array($value) || (is_object($value) && ! method_exists($value, '__toString'))) {
// In Debug: Exception mit Details werfen
if ($this->isDebugMode()) {
$type = is_array($value) ? 'array[' . count($value) . ']' : 'object(' . get_class($value) . ')';
throw new \InvalidArgumentException("Cannot convert {$type} to string in placeholder: {{ {$expr} }}");
}
// In Production: leerer String
return '';
}
return htmlspecialchars((string)$value, $flags, 'UTF-8');
}
@@ -279,6 +307,69 @@ final class PlaceholderReplacer implements StringProcessor
return $params;
}
/**
* Behandelt Array-Werte in Templates - Debug vs Production Verhalten
*/
private function handleArrayValue(string $expr, array $value): string
{
// In Debug-Mode: Exception werfen um Entwickler auf das Problem hinzuweisen
if ($this->isDebugMode()) {
throw new \InvalidArgumentException(
"Template placeholder '{{ {$expr} }}' resolved to an array, which cannot be displayed as string. " .
"Use array access like '{{ {$expr}.0 }}' or iterate with <for> loop."
);
}
// In Production: Sinnvolle Fallback-Werte für häufige Arrays
if (empty($value)) {
return ''; // Leere Arrays werden zu leerem String
}
// Für Arrays mit nur einem Element: das Element zurückgeben
if (count($value) === 1 && ! is_array(reset($value)) && ! is_object(reset($value))) {
return htmlspecialchars((string)reset($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// Für einfache Arrays: Anzahl anzeigen
return '[' . count($value) . ' items]';
}
/**
* Behandelt Object-Werte in Templates - Debug vs Production Verhalten
*/
private function handleObjectValue(string $expr, object $value): string
{
// In Debug-Mode: Exception werfen
if ($this->isDebugMode()) {
throw new \InvalidArgumentException(
"Template placeholder '{{ {$expr} }}' resolved to object of type '" . get_class($value) . "', " .
"which cannot be displayed as string. Implement __toString() method or access specific properties."
);
}
// In Production: Klassenname anzeigen
$className = get_class($value);
$shortName = substr($className, strrpos($className, '\\') + 1);
return '[' . $shortName . ']';
}
/**
* Erkennt ob wir im Debug-Modus sind über AppConfig
*/
private function isDebugMode(): bool
{
try {
/** @var AppConfig $appConfig */
$appConfig = $this->container->get(AppConfig::class);
return $appConfig->isDebug();
} catch (\Throwable $e) {
// Fallback zu Environment-Variable falls AppConfig nicht verfügbar
return ($_ENV['APP_ENV'] ?? 'production') === 'development';
}
}
/**
* Teilt einen Parameter-String in einzelne Parameter auf
*/

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
interface CellFormatter
{
public function format(mixed $value): string;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
enum CellType: string
{
case TEXT = 'text';
case NUMBER = 'number';
case DATE = 'date';
case BOOLEAN = 'boolean';
case HTML = 'html';
case EMAIL = 'email';
case URL = 'url';
case CURRENCY = 'currency';
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Formatters;
use App\Framework\View\Table\CellFormatter;
final readonly class BooleanFormatter implements CellFormatter
{
public function __construct(
private string $trueText = 'Yes',
private string $falseText = 'No',
private ?string $nullText = null,
private bool $useBadges = false
) {
}
public function format(mixed $value): string
{
if ($value === null) {
return $this->nullText ?? '';
}
$isTrue = match (true) {
is_bool($value) => $value,
is_string($value) => in_array(strtolower($value), ['true', '1', 'yes', 'on', 'enabled']),
is_numeric($value) => $value > 0,
default => (bool) $value
};
$text = $isTrue ? $this->trueText : $this->falseText;
if ($this->useBadges) {
$badgeClass = $isTrue ? 'badge-success' : 'badge-secondary';
return "<span class=\"badge {$badgeClass}\">{$text}</span>";
}
return $text;
}
public static function german(): self
{
return new self('Ja', 'Nein');
}
public static function enabled(): self
{
return new self('Enabled', 'Disabled');
}
public static function withBadges(): self
{
return new self('Yes', 'No', null, true);
}
public static function germanWithBadges(): self
{
return new self('Ja', 'Nein', null, true);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Formatters;
use App\Framework\View\Table\CellFormatter;
final readonly class CurrencyFormatter implements CellFormatter
{
public function __construct(
private string $currency = 'EUR',
private int $decimals = 2,
private string $decimalSeparator = ',',
private string $thousandsSeparator = '.'
) {
}
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
$numericValue = is_numeric($value) ? (float) $value : 0;
$formatted = number_format(
$numericValue,
$this->decimals,
$this->decimalSeparator,
$this->thousandsSeparator
);
return match ($this->currency) {
'EUR' => $formatted . ' €',
'USD' => '$ ' . $formatted,
'GBP' => '£ ' . $formatted,
default => $formatted . ' ' . $this->currency
};
}
public static function euro(): self
{
return new self('EUR', 2, ',', '.');
}
public static function dollar(): self
{
return new self('USD', 2, '.', ',');
}
public static function cents(): self
{
return new self('EUR', 0, ',', '.');
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Formatters;
use App\Framework\View\Table\CellFormatter;
final readonly class DateFormatter implements CellFormatter
{
public function __construct(
private string $format = 'Y-m-d H:i:s'
) {
}
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
if ($value instanceof \DateTimeInterface) {
return $value->format($this->format);
}
if (is_string($value) || is_numeric($value)) {
try {
$date = new \DateTime($value);
return $date->format($this->format);
} catch (\Exception) {
return htmlspecialchars((string) $value, ENT_QUOTES);
}
}
return htmlspecialchars((string) $value, ENT_QUOTES);
}
public static function short(): self
{
return new self('Y-m-d');
}
public static function long(): self
{
return new self('d.m.Y H:i:s');
}
public static function german(): self
{
return new self('d.m.Y');
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Formatters;
use App\Framework\View\Table\CellFormatter;
final readonly class MaskedFormatter implements CellFormatter
{
public function __construct(
private string $mask = '********',
private int $visibleChars = 0,
private bool $showLength = false
) {
}
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
$stringValue = (string) $value;
if ($this->visibleChars > 0) {
$visiblePart = substr($stringValue, 0, $this->visibleChars);
$maskedPart = str_repeat('*', max(0, strlen($stringValue) - $this->visibleChars));
$result = $visiblePart . $maskedPart;
} else {
$result = $this->mask;
}
if ($this->showLength) {
$result .= " (Length: " . strlen($stringValue) . ")";
}
return htmlspecialchars($result, ENT_QUOTES);
}
public static function full(): self
{
return new self('********');
}
public static function partial(int $visibleChars): self
{
return new self('********', $visibleChars);
}
public static function withLength(): self
{
return new self('********', 0, true);
}
public static function email(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
$email = (string) $value;
if (! str_contains($email, '@')) {
return '********';
}
[$local, $domain] = explode('@', $email, 2);
$maskedLocal = substr($local, 0, 2) . str_repeat('*', max(0, strlen($local) - 2));
return htmlspecialchars($maskedLocal . '@' . $domain, ENT_QUOTES);
}
};
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Formatters;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\View\Table\CellFormatter;
final readonly class NumberFormatter implements CellFormatter
{
public function __construct(
private int $decimals = 0,
private string $decimalSeparator = ',',
private string $thousandsSeparator = '.',
private ?string $suffix = null,
private ?string $prefix = null
) {
}
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
if (! is_numeric($value)) {
return htmlspecialchars((string) $value, ENT_QUOTES);
}
$formatted = number_format(
(float) $value,
$this->decimals,
$this->decimalSeparator,
$this->thousandsSeparator
);
if ($this->prefix) {
$formatted = $this->prefix . $formatted;
}
if ($this->suffix) {
$formatted .= $this->suffix;
}
return $formatted;
}
public static function integer(): self
{
return new self(0, ',', '.');
}
public static function decimal(): self
{
return new self(2, ',', '.');
}
public static function percentage(): self
{
return new self(1, ',', '.', '%');
}
public static function fileSize(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
if ($value instanceof Byte) {
return $value->toHumanReadable();
}
if (! is_numeric($value)) {
return htmlspecialchars((string) $value, ENT_QUOTES);
}
$byte = Byte::fromBytes((int) $value);
return $byte->toHumanReadable();
}
};
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Formatters;
use App\Framework\View\Table\CellFormatter;
final readonly class StatusFormatter implements CellFormatter
{
public function __construct(
private bool $useIcons = true,
private bool $useBadges = true
) {
}
public function format(mixed $value): string
{
// Value should be an array with status_icon, status_text, and status_class
if (! is_array($value)) {
return (string) $value;
}
$icon = $this->useIcons ? ($value['status_icon'] ?? '') : '';
$text = $value['status_text'] ?? '';
$class = $value['status_class'] ?? 'secondary';
if ($this->useBadges) {
$badgeClass = match ($class) {
'success' => 'status-badge success',
'warning' => 'status-badge warning',
'error', 'danger' => 'status-badge error',
default => 'status-badge secondary'
};
return "<span class=\"{$badgeClass}\">{$icon} {$text}</span>";
}
return $icon ? "{$icon} {$text}" : $text;
}
public static function withBadges(): self
{
return new self(true, true);
}
public static function simple(): self
{
return new self(false, false);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Generators;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableGenerator;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\TableRow;
final readonly class EnvironmentTableGenerator implements TableGenerator
{
public function generate(object|array $data, ?TableOptions $options = null): Table
{
if (! is_array($data)) {
throw new \InvalidArgumentException('EnvironmentTableGenerator expects array data');
}
$columns = [
TableColumn::text('key', 'Variable', 'env-key'),
new TableColumn(
key: 'value',
header: 'Wert',
cssClass: 'env-value',
formatter: $this->createValueFormatter()
),
];
$rows = [];
foreach ($data as $item) {
// Handle both associative arrays and objects
$key = is_array($item) ? $item['key'] : $item->key;
$value = is_array($item) ? $item['value'] : $item->value;
$rows[] = TableRow::fromData([
'key' => $key,
'value' => $value,
], $columns, 'env-row');
}
$tableOptions = $options ?? TableOptions::admin();
return new Table(
columns: $columns,
rows: $rows,
cssClass: 'admin-table',
options: $tableOptions,
id: 'envTable'
);
}
public function supports(object|array $data): bool
{
if (! is_array($data) || empty($data)) {
return false;
}
$firstItem = reset($data);
if (! is_array($firstItem) && ! is_object($firstItem)) {
return false;
}
// Check if it has key/value structure
if (is_array($firstItem)) {
return isset($firstItem['key']) && isset($firstItem['value']);
}
// Check if object has key/value properties
return property_exists($firstItem, 'key') && property_exists($firstItem, 'value');
}
private function createValueFormatter(): \App\Framework\View\Table\CellFormatter
{
return new class () implements \App\Framework\View\Table\CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
$stringValue = (string) $value;
// Already masked values pass through
if ($stringValue === '********') {
return $stringValue;
}
return htmlspecialchars($stringValue, ENT_QUOTES);
}
};
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Generators;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableGenerator;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\TableRow;
final readonly class GenericArrayTableGenerator implements TableGenerator
{
public function generate(object|array $data, ?TableOptions $options = null): Table
{
if (is_object($data)) {
return $this->generateFromObject($data, $options);
}
if (empty($data)) {
return new Table([], [], null, $options);
}
// Check if it's a simple associative array (key => value)
if ($this->isSimpleAssociativeArray($data)) {
return $this->fromKeyValuePairs($data, $options);
}
// Check if it's an array of objects/arrays with consistent structure
$firstItem = reset($data);
if (is_array($firstItem)) {
return $this->fromArrayOfArrays($data, $options);
}
if (is_object($firstItem)) {
return $this->fromArrayOfObjects($data, $options);
}
// Fallback: treat as simple values
return $this->fromSimpleValues($data, $options);
}
public function supports(object|array $data): bool
{
// Generic generator supports everything as fallback
return true;
}
private function generateFromObject(object $data, ?TableOptions $options = null): Table
{
$properties = get_object_vars($data);
return $this->fromKeyValuePairs($properties, $options);
}
private function isSimpleAssociativeArray(array $data): bool
{
if (empty($data)) {
return false;
}
// Check if all values are scalar (not arrays or objects)
foreach ($data as $value) {
if (is_array($value) || is_object($value)) {
return false;
}
}
// Check if it's not a simple indexed array
return array_keys($data) !== range(0, count($data) - 1);
}
private function fromKeyValuePairs(array $data, ?TableOptions $options = null): Table
{
$columns = [
TableColumn::text('key', 'Key'),
TableColumn::text('value', 'Value'),
];
$rows = [];
foreach ($data as $key => $value) {
$rows[] = TableRow::fromData([
'key' => $key,
'value' => $this->formatValue($value),
], $columns);
}
return new Table($columns, $rows, null, $options);
}
private function fromArrayOfArrays(array $data, ?TableOptions $options = null): Table
{
$firstItem = reset($data);
$columns = [];
// Auto-generate columns from first item keys
foreach (array_keys($firstItem) as $key) {
$columns[] = TableColumn::text($key, $this->humanizeKey($key));
}
return $this->createTableFromColumns($data, $columns, $options);
}
private function fromArrayOfObjects(array $data, ?TableOptions $options = null): Table
{
// Convert objects to arrays first
$arrayData = array_map(function ($item) {
if (is_object($item)) {
return get_object_vars($item);
}
return $item;
}, $data);
return $this->fromArrayOfArrays($arrayData, $options);
}
private function fromSimpleValues(array $data, ?TableOptions $options = null): Table
{
$columns = [TableColumn::text('value', 'Value')];
$rows = [];
foreach ($data as $value) {
$rows[] = TableRow::fromData(['value' => $this->formatValue($value)], $columns);
}
return new Table($columns, $rows, null, $options);
}
private function createTableFromColumns(array $data, array $columns, ?TableOptions $options = null): Table
{
$rows = [];
foreach ($data as $rowData) {
$rows[] = TableRow::fromData($rowData, $columns);
}
return new Table($columns, $rows, null, $options);
}
private function humanizeKey(string $key): string
{
// Convert snake_case to words
$humanized = str_replace('_', ' ', $key);
// Convert camelCase to words
$humanized = preg_replace('/([a-z])([A-Z])/', '$1 $2', $humanized);
// Capitalize first letter of each word
return ucwords(strtolower($humanized));
}
private function formatValue(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_array($value) || is_object($value)) {
return json_encode($value) ?: '[serialization failed]';
}
return (string) $value;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Generators;
use App\Framework\View\Table\CellFormatter;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableGenerator;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\TableRow;
final readonly class HealthCheckTableGenerator implements TableGenerator
{
public function generate(object|array $data, ?TableOptions $options = null): Table
{
if (! is_array($data)) {
throw new \InvalidArgumentException('HealthCheckTableGenerator expects array data');
}
$columns = [
new TableColumn(
key: 'componentName',
header: 'Component',
cssClass: 'health-component',
formatter: $this->createComponentFormatter()
),
new TableColumn(
key: 'statusText',
header: 'Status',
cssClass: 'health-status',
formatter: $this->createStatusFormatter()
),
TableColumn::text('message', 'Message', 'health-message'),
new TableColumn(
key: 'responseTime',
header: 'Response Time',
cssClass: 'health-response-time',
formatter: $this->createResponseTimeFormatter()
),
];
$rows = [];
foreach ($data as $check) {
// Extract status class from the check data
$statusClass = is_array($check) ? ($check['statusClass'] ?? 'unknown') : ($check->statusClass ?? 'unknown');
$rows[] = TableRow::fromData($check, $columns, "health-check health-check--{$statusClass}");
}
$tableOptions = $options ?? new TableOptions(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'No health checks available'
);
return new Table(
columns: $columns,
rows: $rows,
cssClass: 'admin-table health-check-table',
options: $tableOptions,
id: 'healthCheckTable'
);
}
public function supports(object|array $data): bool
{
if (! is_array($data) || empty($data)) {
return false;
}
$firstItem = reset($data);
if (! is_array($firstItem) && ! is_object($firstItem)) {
return false;
}
// Check if it has health check structure
if (is_array($firstItem)) {
return isset($firstItem['componentName']) && isset($firstItem['statusText']);
}
// Check if object has health check properties
return property_exists($firstItem, 'componentName') && property_exists($firstItem, 'statusText');
}
private function createComponentFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
return '<strong>' . htmlspecialchars((string) $value, ENT_QUOTES) . '</strong>';
}
};
}
private function createStatusFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
$status = strtolower((string) $value);
$statusClass = match ($status) {
'healthy', 'ok', 'success' => 'success',
'warning', 'degraded' => 'warning',
'error', 'failed', 'unhealthy' => 'error',
default => 'unknown'
};
$icon = match ($statusClass) {
'success' => '✅',
'warning' => '⚠️',
'error' => '❌',
default => '❓'
};
return sprintf(
'<span class="admin-table__status admin-table__status--%s">%s %s</span>',
$statusClass,
$icon,
htmlspecialchars(ucfirst((string) $value), ENT_QUOTES)
);
}
};
}
private function createResponseTimeFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '-';
}
$stringValue = (string) $value;
// If already formatted with unit (e.g., "12ms"), return as is
if (preg_match('/^\d+(\.\d+)?\s?(ms|s)$/i', $stringValue)) {
return htmlspecialchars($stringValue, ENT_QUOTES);
}
// If numeric, format as milliseconds
if (is_numeric($value)) {
$ms = (float) $value;
if ($ms < 1000) {
return sprintf('%.1fms', $ms);
} else {
return sprintf('%.2fs', $ms / 1000);
}
}
return htmlspecialchars($stringValue, ENT_QUOTES);
}
};
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Generators;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableGenerator;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\TableRow;
final readonly class PhpInfoTableGenerator implements TableGenerator
{
public function generate(object|array $data, ?TableOptions $options = null): Table
{
if (! is_array($data)) {
throw new \InvalidArgumentException('PhpInfoTableGenerator expects array data');
}
$columns = [
TableColumn::text('key', 'Setting', 'phpinfo-key'),
TableColumn::text('value', 'Value', 'phpinfo-value'),
];
$rows = [];
foreach ($data as $item) {
$rows[] = TableRow::fromData($item, $columns, 'phpinfo-row');
}
$tableOptions = $options ?? new TableOptions(
striped: false,
bordered: false,
hover: true,
responsive: true,
emptyMessage: 'No PHP information available'
);
return new Table(
columns: $columns,
rows: $rows,
cssClass: 'admin-table phpinfo-table',
options: $tableOptions,
id: 'phpInfoTable'
);
}
public function supports(object|array $data): bool
{
if (! is_array($data) || empty($data)) {
return false;
}
$firstItem = reset($data);
if (! is_array($firstItem)) {
return false;
}
// Check if it has key/value structure typical for PHP info
return isset($firstItem['key']) && isset($firstItem['value']);
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Generators;
use App\Framework\View\Table\CellFormatter;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableGenerator;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\TableRow;
final readonly class RouteTableGenerator implements TableGenerator
{
public function generate(object|array $data, ?TableOptions $options = null): Table
{
if (! is_array($data)) {
throw new \InvalidArgumentException('RouteTableGenerator expects array data');
}
$columns = [
new TableColumn(
key: 'method',
header: 'Method',
cssClass: 'route-method',
formatter: $this->createMethodFormatter()
),
TableColumn::text('path', 'Path', 'route-path'),
new TableColumn(
key: 'name',
header: 'Name',
cssClass: 'route-name',
formatter: $this->createNameFormatter()
),
TableColumn::text('controller', 'Controller', 'route-controller'),
TableColumn::text('handler', 'Handler', 'route-handler'),
new TableColumn(
key: 'is_protected',
header: 'Auth',
cssClass: 'route-auth',
formatter: $this->createAuthFormatter()
),
];
$rows = [];
foreach ($data as $route) {
$rows[] = TableRow::fromData($route, $columns, 'route-row');
}
$tableOptions = $options ?? new TableOptions(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'No routes found'
);
return new Table(
columns: $columns,
rows: $rows,
cssClass: 'admin-table route-table',
options: $tableOptions,
id: 'routesTable'
);
}
public function supports(object|array $data): bool
{
if (! is_array($data) || empty($data)) {
return false;
}
$firstItem = reset($data);
if (! is_array($firstItem)) {
return false;
}
// Check if it has route structure
return isset($firstItem['path']) && isset($firstItem['method']);
}
private function createMethodFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
$method = strtoupper((string) $value);
$colorClass = match ($method) {
'GET' => 'success',
'POST' => 'primary',
'PUT', 'PATCH' => 'warning',
'DELETE' => 'error',
default => 'secondary'
};
return sprintf(
'<span class="method-badge method-badge--%s">%s</span>',
$colorClass,
htmlspecialchars($method, ENT_QUOTES)
);
}
};
}
private function createNameFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '<em class="text-muted">unnamed</em>';
}
// Handle backed enums by accessing their value property
if ($value instanceof \BackedEnum) {
return '<code>' . htmlspecialchars((string) $value->value, ENT_QUOTES) . '</code>';
}
return '<code>' . htmlspecialchars((string) $value, ENT_QUOTES) . '</code>';
}
};
}
private function createAuthFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
$isProtected = (bool) $value;
if ($isProtected) {
return '<span class="auth-badge auth-badge--protected" title="Protected">🔒</span>';
} else {
return '<span class="auth-badge auth-badge--public" title="Public">🌐</span>';
}
}
};
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
final readonly class Table
{
/**
* @param TableColumn[] $columns
* @param TableRow[] $rows
*/
public function __construct(
public array $columns,
public array $rows,
public ?string $cssClass = null,
public ?TableOptions $options = null,
public ?string $id = null
) {
}
public function render(): string
{
$options = $this->options ?? new TableOptions();
$classString = $this->buildClassString($options);
$attributesString = $this->buildAttributesString($options);
$idString = $this->id ? " id=\"{$this->id}\"" : '';
$thead = $this->renderThead();
$tbody = $this->renderTbody($options);
return "<table{$classString}{$idString}{$attributesString}>{$thead}{$tbody}</table>";
}
private function renderThead(): string
{
$headerCells = array_map(fn (TableColumn $column) => $column->renderHeader(), $this->columns);
return "<thead><tr>" . implode('', $headerCells) . "</tr></thead>";
}
private function renderTbody(TableOptions $options): string
{
if (empty($this->rows)) {
$colspan = count($this->columns);
$emptyMessage = $options->emptyMessage ?? 'No data available';
return "<tbody><tr><td colspan=\"{$colspan}\" class=\"text-center empty-message\">{$emptyMessage}</td></tr></tbody>";
}
$rowsHtml = array_map(fn (TableRow $row) => $row->render(), $this->rows);
return "<tbody>" . implode('', $rowsHtml) . "</tbody>";
}
private function buildClassString(TableOptions $options): string
{
$classes = [];
if ($this->cssClass) {
$classes[] = $this->cssClass;
}
if ($options->striped) {
$classes[] = 'table-striped';
}
if ($options->bordered) {
$classes[] = 'table-bordered';
}
if ($options->hover) {
$classes[] = 'table-hover';
}
if ($options->responsive) {
$classes[] = 'table-responsive';
}
return $classes ? ' class="' . implode(' ', $classes) . '"' : '';
}
private function buildAttributesString(TableOptions $options): string
{
if (! $options->tableAttributes) {
return '';
}
$parts = [];
foreach ($options->tableAttributes as $key => $value) {
$parts[] = $key . '="' . htmlspecialchars((string) $value, ENT_QUOTES) . '"';
}
return $parts ? ' ' . implode(' ', $parts) : '';
}
/**
* Create table from array data with column definitions
*/
public static function fromArray(array $data, array $columnDefs, ?TableOptions $options = null): self
{
$columns = [];
$rows = [];
// Build columns
foreach ($columnDefs as $key => $def) {
if (is_string($def)) {
// Simple string header
$columns[] = TableColumn::text($key, $def);
} elseif ($def instanceof TableColumn) {
// Already a TableColumn
$columns[] = $def;
} elseif (is_array($def)) {
// Array definition
$columns[] = new TableColumn(
key: $key,
header: $def['header'] ?? $key,
cssClass: $def['class'] ?? null,
formatter: $def['formatter'] ?? null,
sortable: $def['sortable'] ?? false,
width: $def['width'] ?? null,
defaultType: $def['type'] ?? null
);
}
}
// Build rows
foreach ($data as $rowData) {
$rows[] = TableRow::fromData($rowData, $columns);
}
return new self($columns, $rows, null, $options);
}
/**
* Create simple table from 2D array
*/
public static function fromSimpleArray(array $headers, array $data, ?TableOptions $options = null): self
{
$columns = array_map(
fn ($header, $index) => TableColumn::text((string) $index, $header),
$headers,
array_keys($headers)
);
$rows = array_map(
fn ($rowData) => TableRow::fromValues($rowData),
$data
);
return new self($columns, $rows, null, $options);
}
/**
* Create environment variables table
*/
public static function forEnvironmentVars(array $env): self
{
$columns = [
TableColumn::text('key', 'Variable', 'env-key'),
TableColumn::text('value', 'Wert', 'env-value'),
];
$rows = [];
foreach ($env as $key => $value) {
// Mask sensitive values - ensure key is string
$displayValue = self::maskSensitiveValue((string) $key, $value);
$rows[] = TableRow::fromData([
'key' => (string) $key,
'value' => $displayValue,
], $columns, 'env-row');
}
return new self(
columns: $columns,
rows: $rows,
cssClass: 'admin-table',
options: TableOptions::admin(),
id: 'envTable'
);
}
/**
* Create migrations table
*/
public static function forMigrations(array $migrations): self
{
$columns = [
TableColumn::withFormatter(
'status',
'Status',
Formatters\StatusFormatter::withBadges(),
'status-col'
),
TableColumn::text('version', 'Version', 'version-col'),
TableColumn::text('description', 'Description', 'description-col'),
TableColumn::withFormatter(
'applied',
'Applied',
Formatters\BooleanFormatter::germanWithBadges(),
'applied-col'
),
];
$rows = [];
foreach ($migrations as $migration) {
$statusData = [
'status_icon' => $migration['status_icon'] ?? '',
'status_text' => $migration['status_text'] ?? '',
'status_class' => $migration['status_class'] ?? 'secondary',
];
$rows[] = TableRow::fromData([
'status' => $statusData,
'version' => $migration['version'] ?? '',
'description' => $migration['description'] ?? '',
'applied' => $migration['applied'] ?? false,
], $columns, 'migration-row');
}
return new self(
columns: $columns,
rows: $rows,
cssClass: 'admin-table',
options: TableOptions::admin(),
id: 'migrationsTable'
);
}
private static function maskSensitiveValue(string $key, mixed $value): string
{
$sensitiveKeys = ['password', 'secret', 'key', 'token', 'auth'];
foreach ($sensitiveKeys as $sensitiveKey) {
if (stripos($key, $sensitiveKey) !== false) {
return '********';
}
}
// Handle arrays and objects properly
if (is_array($value)) {
$encoded = json_encode($value, JSON_PRETTY_PRINT);
return $encoded ?: '[Array]';
}
if (is_object($value)) {
if (method_exists($value, '__toString')) {
return (string) $value;
}
return get_class($value);
}
return (string) $value;
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
use App\Framework\View\Table\ValueObjects\ColumnDefinition;
use App\Framework\View\Table\ValueObjects\TableRowData;
final class TableBuilder
{
/** @var ColumnDefinition[] */
private array $columns = [];
/** @var TableRowData[] */
private array $rows = [];
private ?string $cssClass = null;
private ?TableOptions $options = null;
private ?string $id = null;
private function __construct()
{
}
/**
* Create a new TableBuilder with column definitions
*
* @param ColumnDefinition ...$columns
*/
public static function fromColumns(ColumnDefinition ...$columns): self
{
$builder = new self();
$builder->columns = $columns;
return $builder;
}
/**
* Add a single row to the table
* Values must match the column order and types
*/
public function addRow(mixed ...$values): self
{
$this->validateRowData($values);
$this->rows[] = TableRowData::fromValues($this->columns, ...$values);
return $this;
}
/**
* Add multiple rows using a callback to extract values
*
* @param array $data
* @param callable $extractor Function that takes a data item and returns an array of values
*/
public function addRows(array $data, callable $extractor): self
{
foreach ($data as $item) {
$values = $extractor($item);
$this->addRow(...$values);
}
return $this;
}
/**
* Add multiple rows from array data where each row is an array
*/
public function addRowsFromArrays(array $rows): self
{
foreach ($rows as $rowData) {
if (! is_array($rowData)) {
throw new \InvalidArgumentException('Each row must be an array of values');
}
$this->addRow(...$rowData);
}
return $this;
}
/**
* Set CSS class for the table
*/
public function withCssClass(string $cssClass): self
{
$this->cssClass = $cssClass;
return $this;
}
/**
* Set table options (striped, bordered, etc.)
*/
public function withOptions(TableOptions $options): self
{
$this->options = $options;
return $this;
}
/**
* Set table ID
*/
public function withId(string $id): self
{
$this->id = $id;
return $this;
}
/**
* Build the final Table object
*/
public function build(): Table
{
// Convert ColumnDefinitions to TableColumns
$tableColumns = array_map(
fn (ColumnDefinition $col) => $col->toTableColumn(),
$this->columns
);
// Convert TableRowData to TableRows
$tableRows = array_map(
fn (TableRowData $rowData) => $rowData->toTableRow($tableColumns),
$this->rows
);
return new Table(
columns: $tableColumns,
rows: $tableRows,
cssClass: $this->cssClass,
options: $this->options,
id: $this->id
);
}
/**
* Validate that row data matches column expectations
*/
private function validateRowData(array $values): void
{
$columnCount = count($this->columns);
$valueCount = count($values);
if ($valueCount !== $columnCount) {
throw new \InvalidArgumentException(
"Row value count ({$valueCount}) does not match column count ({$columnCount})"
);
}
// Additional type validation can be added here based on column definitions
foreach ($this->columns as $index => $column) {
$value = $values[$index];
$column->validateValue($value);
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
final readonly class TableCell
{
public function __construct(
public mixed $value,
public ?string $cssClass = null,
public ?array $attributes = null,
public ?CellType $type = null,
public ?CellFormatter $formatter = null
) {
}
public function render(): string
{
$formattedValue = $this->formatter
? $this->formatter->format($this->value)
: $this->formatByType();
$attributesString = $this->buildAttributesString();
$classString = $this->cssClass ? " class=\"{$this->cssClass}\"" : '';
return "<td{$classString}{$attributesString}>{$formattedValue}</td>";
}
private function formatByType(): string
{
if ($this->value === null) {
return '';
}
return match ($this->type) {
CellType::HTML => (string) $this->value,
CellType::BOOLEAN => $this->value ? 'Yes' : 'No',
CellType::EMAIL => "<a href=\"mailto:{$this->value}\">" . htmlspecialchars((string) $this->value, ENT_QUOTES) . "</a>",
CellType::URL => "<a href=\"{$this->value}\" target=\"_blank\">" . htmlspecialchars((string) $this->value, ENT_QUOTES) . "</a>",
default => htmlspecialchars((string) $this->value, ENT_QUOTES | ENT_HTML5, 'UTF-8')
};
}
private function buildAttributesString(): string
{
if (! $this->attributes) {
return '';
}
$parts = [];
foreach ($this->attributes as $key => $value) {
$parts[] = $key . '="' . htmlspecialchars((string) $value, ENT_QUOTES) . '"';
}
return $parts ? ' ' . implode(' ', $parts) : '';
}
public static function text(mixed $value, ?string $cssClass = null): self
{
return new self($value, $cssClass, null, CellType::TEXT);
}
public static function html(string $html, ?string $cssClass = null): self
{
return new self($html, $cssClass, null, CellType::HTML);
}
public static function boolean(bool $value, ?string $cssClass = null): self
{
return new self($value, $cssClass, null, CellType::BOOLEAN);
}
public static function email(string $email, ?string $cssClass = null): self
{
return new self($email, $cssClass, null, CellType::EMAIL);
}
public static function url(string $url, ?string $cssClass = null): self
{
return new self($url, $cssClass, null, CellType::URL);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
final readonly class TableColumn
{
public function __construct(
public string $key,
public string $header,
public ?string $cssClass = null,
public ?CellFormatter $formatter = null,
public bool $sortable = false,
public ?string $width = null,
public ?CellType $defaultType = null
) {
}
public function renderHeader(): string
{
$classString = $this->cssClass ? " class=\"{$this->cssClass}\"" : '';
$styleString = $this->width ? " style=\"width: {$this->width}\"" : '';
$sortableClass = $this->sortable ? ' sortable' : '';
return "<th{$classString}{$styleString}><span class=\"header-content{$sortableClass}\">{$this->header}</span></th>";
}
public function createCell(mixed $value): TableCell
{
return new TableCell(
value: $value,
cssClass: $this->cssClass,
type: $this->defaultType,
formatter: $this->formatter
);
}
public static function text(string $key, string $header, ?string $cssClass = null): self
{
return new self($key, $header, $cssClass, null, false, null, CellType::TEXT);
}
public static function sortable(string $key, string $header, ?string $cssClass = null): self
{
return new self($key, $header, $cssClass, null, true, null, CellType::TEXT);
}
public static function withFormatter(
string $key,
string $header,
CellFormatter $formatter,
?string $cssClass = null
): self {
return new self($key, $header, $cssClass, $formatter, false, null);
}
public static function boolean(string $key, string $header, ?string $cssClass = null): self
{
return new self($key, $header, $cssClass, null, false, null, CellType::BOOLEAN);
}
public static function email(string $key, string $header, ?string $cssClass = null): self
{
return new self($key, $header, $cssClass, null, false, null, CellType::EMAIL);
}
public static function url(string $key, string $header, ?string $cssClass = null): self
{
return new self($key, $header, $cssClass, null, false, null, CellType::URL);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
interface TableGenerator
{
/**
* Generate table from data
*/
public function generate(object|array $data, ?TableOptions $options = null): Table;
/**
* Check if this generator can handle the given data structure
*/
public function supports(object|array $data): bool;
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
final readonly class TableOptions
{
public function __construct(
public bool $striped = true,
public bool $bordered = false,
public bool $hover = true,
public bool $responsive = true,
public ?string $emptyMessage = 'No data available',
public ?array $tableAttributes = null
) {
}
public static function admin(): self
{
return new self(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'Keine Daten verfügbar'
);
}
public static function simple(): self
{
return new self(
striped: false,
bordered: false,
hover: false,
responsive: false
);
}
}

View File

@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RenderContext;
final readonly class TableProcessor implements DomProcessor
{
public function __construct()
{
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: CONSTRUCTOR CALLED - TableProcessor initialized\n", FILE_APPEND | LOCK_EX);
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
{
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: Starting table processing\n", FILE_APPEND | LOCK_EX);
// Find all <table-data> elements
$tableDataNodes = $dom->document->querySelectorAll('table-data[source]');
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: Found " . count($tableDataNodes) . " table-data elements\n", FILE_APPEND | LOCK_EX);
foreach ($tableDataNodes as $node) {
$source = $node->getAttribute('source');
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: Processing table-data with source: '{$source}'\n", FILE_APPEND | LOCK_EX);
// Get data from context
$data = $this->resolveDataSource($context->data, $source);
if ($data === null) {
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: No data found for source '{$source}'\n", FILE_APPEND | LOCK_EX);
continue;
}
// Get table configuration from attributes
$tableConfig = $this->parseTableConfig($node);
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: Table config: " . json_encode($tableConfig) . "\n", FILE_APPEND | LOCK_EX);
// Generate table based on data type
$table = $this->createTable($data, $tableConfig);
// Check if we should wrap the table in a container
$wrapInContainer = $node->getAttribute('no-container') !== 'true';
$containerClass = $node->getAttribute('container-class') ?: 'table-container';
$containerStyle = $node->getAttribute('container-style') ?: '';
// Replace the table-data element with rendered table
$tableHtml = $table->render();
// Wrap table in container div if needed
if ($wrapInContainer) {
$styleAttr = $containerStyle ? " style=\"{$containerStyle}\"" : '';
$tableHtml = "<div class=\"{$containerClass}\"{$styleAttr}>{$tableHtml}</div>";
}
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: Generated table HTML (length: " . strlen($tableHtml) . ")\n", FILE_APPEND | LOCK_EX);
// Create document fragment and replace
$replacement = $dom->document->createDocumentFragment();
@$replacement->appendXML($tableHtml);
$node->parentNode?->replaceChild($replacement, $node);
file_put_contents('/tmp/debug.log', "🔧 TableProcessor: Successfully replaced table-data element\n", FILE_APPEND | LOCK_EX);
}
return $dom;
}
private function resolveDataSource(array $contextData, string $source): mixed
{
// Handle nested properties like "stats.memory"
$keys = explode('.', $source);
$value = $contextData;
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value)) {
if (isset($value->$key)) {
$value = $value->$key;
} elseif (method_exists($value, $key)) {
$value = $value->$key();
} elseif (method_exists($value, 'get' . ucfirst($key))) {
$getterMethod = 'get' . ucfirst($key);
$value = $value->$getterMethod();
} else {
return null;
}
} else {
return null;
}
}
return $value;
}
private function parseTableConfig($node): array
{
return [
'class' => $node->getAttribute('class') ?: 'table',
'id' => $node->getAttribute('id') ?: null,
'striped' => $node->getAttribute('striped') === 'true',
'bordered' => $node->getAttribute('bordered') === 'true',
'hover' => $node->getAttribute('hover') === 'true',
'responsive' => $node->getAttribute('responsive') !== 'false',
'empty-message' => $node->getAttribute('empty-message') ?: 'No data available',
'headers' => $node->getAttribute('headers'),
'fields' => $node->getAttribute('fields'),
'type' => $node->getAttribute('type') ?: 'auto',
];
}
private function createTable(mixed $data, array $config): Table
{
// If data is already a Table object, use it directly
if ($data instanceof Table) {
return $data;
}
// Fallback to basic table creation for backward compatibility
return match ($config['type']) {
'environment' => $this->createEnvironmentTable($data, $config),
'phpinfo' => $this->createPhpInfoTable($data, $config),
'routes' => $this->createRouteTable($data, $config),
'simple' => $this->createSimpleTable($data, $config),
'auto' => $this->createAutoTable($data, $config),
default => $this->createAutoTable($data, $config)
};
}
private function createEnvironmentTable(array $data, array $config): Table
{
return Table::forEnvironmentVars($data);
}
private function createPhpInfoTable(array $data, array $config): Table
{
$columns = [
TableColumn::text('key', 'Setting', 'phpinfo-key'),
TableColumn::text('value', 'Value', 'phpinfo-value'),
];
$rows = [];
foreach ($data as $item) {
$rows[] = TableRow::fromData($item, $columns, 'phpinfo-row');
}
$options = new TableOptions(
striped: $config['striped'],
bordered: $config['bordered'],
hover: $config['hover'],
responsive: $config['responsive'],
emptyMessage: $config['empty-message']
);
return new Table(
columns: $columns,
rows: $rows,
cssClass: 'admin-table phpinfo-table',
options: $options,
id: $config['id'] ?: 'phpInfoTable'
);
}
private function createRouteTable(array $data, array $config): Table
{
$columns = [
new TableColumn(
key: 'method',
header: 'Method',
cssClass: 'route-method',
formatter: $this->createMethodFormatter()
),
TableColumn::text('path', 'Path', 'route-path'),
new TableColumn(
key: 'name',
header: 'Name',
cssClass: 'route-name',
formatter: $this->createNameFormatter()
),
TableColumn::text('controller', 'Controller', 'route-controller'),
TableColumn::text('handler', 'Handler', 'route-handler'),
new TableColumn(
key: 'is_protected',
header: 'Auth',
cssClass: 'route-auth',
formatter: $this->createAuthFormatter()
),
];
$rows = [];
foreach ($data as $route) {
$rows[] = TableRow::fromData($route, $columns, 'route-row');
}
$options = new TableOptions(
striped: $config['striped'],
bordered: $config['bordered'],
hover: $config['hover'],
responsive: $config['responsive'],
emptyMessage: $config['empty-message']
);
return new Table(
columns: $columns,
rows: $rows,
cssClass: 'admin-table route-table',
options: $options,
id: $config['id'] ?: 'routesTable'
);
}
private function createSimpleTable(array $data, array $config): Table
{
$headers = $config['headers'] ? explode(',', $config['headers']) : [];
$options = new TableOptions(
striped: $config['striped'],
bordered: $config['bordered'],
hover: $config['hover'],
responsive: $config['responsive'],
emptyMessage: $config['empty-message']
);
return Table::fromSimpleArray($headers, $data, $options);
}
private function createAutoTable(array $data, array $config): Table
{
if (empty($data)) {
return new Table([], [], $config['class']);
}
// Auto-detect structure
$firstItem = reset($data);
if (is_array($firstItem)) {
// Array of arrays - use keys as column definitions
return $this->createTableFromArrayData($data, $config);
}
if (is_object($firstItem)) {
// Array of objects - use properties as columns
return $this->createTableFromObjectData($data, $config);
}
// Simple values - create single column table
return $this->createSingleColumnTable($data, $config);
}
private function createTableFromArrayData(array $data, array $config): Table
{
$firstItem = reset($data);
$columnDefs = [];
// Create column definitions from first item keys
foreach (array_keys($firstItem) as $key) {
$columnDefs[$key] = ucfirst(str_replace('_', ' ', $key));
}
$options = new TableOptions(
striped: $config['striped'],
bordered: $config['bordered'],
hover: $config['hover'],
responsive: $config['responsive'],
emptyMessage: $config['empty-message']
);
return Table::fromArray($data, $columnDefs, $options);
}
private function createTableFromObjectData(array $data, array $config): Table
{
// Convert objects to arrays for processing
$arrayData = array_map(function ($item) {
if (is_object($item)) {
return get_object_vars($item);
}
return $item;
}, $data);
return $this->createTableFromArrayData($arrayData, $config);
}
private function createSingleColumnTable(array $data, array $config): Table
{
$headers = ['Value'];
$tableData = array_map(fn ($value) => [$value], $data);
$options = new TableOptions(
striped: $config['striped'],
bordered: $config['bordered'],
hover: $config['hover'],
responsive: $config['responsive'],
emptyMessage: $config['empty-message']
);
return Table::fromSimpleArray($headers, $tableData, $options);
}
private function createMethodFormatter(): \App\Framework\View\Table\CellFormatter
{
return new class () implements \App\Framework\View\Table\CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
$method = strtoupper((string) $value);
$colorClass = match ($method) {
'GET' => 'success',
'POST' => 'primary',
'PUT', 'PATCH' => 'warning',
'DELETE' => 'error',
default => 'secondary'
};
return sprintf(
'<span class="method-badge method-badge--%s">%s</span>',
$colorClass,
htmlspecialchars($method, ENT_QUOTES)
);
}
};
}
private function createNameFormatter(): \App\Framework\View\Table\CellFormatter
{
return new class () implements \App\Framework\View\Table\CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '<em class="text-muted">unnamed</em>';
}
// Handle backed enums by accessing their value property
if ($value instanceof \BackedEnum) {
return '<code>' . htmlspecialchars((string) $value->value, ENT_QUOTES) . '</code>';
}
return '<code>' . htmlspecialchars((string) $value, ENT_QUOTES) . '</code>';
}
};
}
private function createAuthFormatter(): \App\Framework\View\Table\CellFormatter
{
return new class () implements \App\Framework\View\Table\CellFormatter {
public function format(mixed $value): string
{
$isProtected = (bool) $value;
if ($isProtected) {
return '<span class="auth-badge auth-badge--protected" title="Protected">🔒</span>';
} else {
return '<span class="auth-badge auth-badge--public" title="Public">🌐</span>';
}
}
};
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table;
final readonly class TableRow
{
/**
* @param TableCell[] $cells
*/
public function __construct(
public array $cells,
public ?string $cssClass = null,
public ?array $attributes = null
) {
}
public function render(): string
{
$attributesString = $this->buildAttributesString();
$classString = $this->cssClass ? " class=\"{$this->cssClass}\"" : '';
$cellsHtml = array_map(fn (TableCell $cell) => $cell->render(), $this->cells);
return "<tr{$classString}{$attributesString}>" . implode('', $cellsHtml) . "</tr>";
}
private function buildAttributesString(): string
{
if (! $this->attributes) {
return '';
}
$parts = [];
foreach ($this->attributes as $key => $value) {
$parts[] = $key . '="' . htmlspecialchars((string) $value, ENT_QUOTES) . '"';
}
return $parts ? ' ' . implode(' ', $parts) : '';
}
/**
* @param TableColumn[] $columns
*/
public static function fromData(array $data, array $columns, ?string $cssClass = null): self
{
$cells = [];
foreach ($columns as $column) {
$value = $data[$column->key] ?? null;
$cells[] = $column->createCell($value);
}
return new self($cells, $cssClass);
}
/**
* @param mixed[] $values
*/
public static function fromValues(array $values, ?string $cssClass = null): self
{
$cells = array_map(fn ($value) => TableCell::text($value), $values);
return new self($cells, $cssClass);
}
public static function withCells(TableCell ...$cells): self
{
return new self($cells);
}
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\ValueObjects;
use App\Framework\View\Table\CellFormatter;
use App\Framework\View\Table\CellType;
use App\Framework\View\Table\TableColumn;
final readonly class ColumnDefinition
{
public function __construct(
public string $key,
public string $header,
public ColumnType $type,
public ?string $cssClass = null,
public ?CellFormatter $formatter = null,
public bool $sortable = false,
public ?string $width = null
) {
}
/**
* Create a text column
*/
public static function text(string $key, string $header, ?string $cssClass = null): self
{
return new self(
key: $key,
header: $header,
type: ColumnType::TEXT,
cssClass: $cssClass
);
}
/**
* Create a status column with formatter
*/
public static function status(string $key, string $header, ?string $cssClass = null): self
{
return new self(
key: $key,
header: $header,
type: ColumnType::STATUS,
cssClass: $cssClass,
formatter: \App\Framework\View\Table\Formatters\StatusFormatter::withBadges()
);
}
/**
* Create a boolean column with formatter
*/
public static function boolean(string $key, string $header, ?string $cssClass = null): self
{
return new self(
key: $key,
header: $header,
type: ColumnType::BOOLEAN,
cssClass: $cssClass,
formatter: \App\Framework\View\Table\Formatters\BooleanFormatter::germanWithBadges()
);
}
/**
* Create a column with custom formatter
*/
public static function withFormatter(
string $key,
string $header,
CellFormatter $formatter,
?string $cssClass = null
): self {
return new self(
key: $key,
header: $header,
type: ColumnType::CUSTOM,
cssClass: $cssClass,
formatter: $formatter
);
}
/**
* Create a numeric column
*/
public static function numeric(string $key, string $header, ?string $cssClass = null): self
{
return new self(
key: $key,
header: $header,
type: ColumnType::NUMERIC,
cssClass: $cssClass
);
}
/**
* Create a date column
*/
public static function date(string $key, string $header, ?string $cssClass = null): self
{
return new self(
key: $key,
header: $header,
type: ColumnType::DATE,
cssClass: $cssClass
);
}
/**
* Add sortable flag
*/
public function sortable(): self
{
return new self(
key: $this->key,
header: $this->header,
type: $this->type,
cssClass: $this->cssClass,
formatter: $this->formatter,
sortable: true,
width: $this->width
);
}
/**
* Set column width
*/
public function withWidth(string $width): self
{
return new self(
key: $this->key,
header: $this->header,
type: $this->type,
cssClass: $this->cssClass,
formatter: $this->formatter,
sortable: $this->sortable,
width: $width
);
}
/**
* Validate a value against this column's type expectations
*/
public function validateValue(mixed $value): void
{
match ($this->type) {
ColumnType::TEXT => $this->validateTextValue($value),
ColumnType::NUMERIC => $this->validateNumericValue($value),
ColumnType::BOOLEAN => $this->validateBooleanValue($value),
ColumnType::DATE => $this->validateDateValue($value),
ColumnType::STATUS => $this->validateStatusValue($value),
ColumnType::CUSTOM => null, // Custom columns can accept any value
};
}
/**
* Convert to TableColumn for use in existing Table class
*/
public function toTableColumn(): TableColumn
{
// Map our ColumnType to CellType
$cellType = match ($this->type) {
ColumnType::TEXT => CellType::TEXT,
ColumnType::NUMERIC => CellType::NUMBER,
ColumnType::BOOLEAN => CellType::BOOLEAN,
ColumnType::DATE => CellType::DATE,
ColumnType::STATUS => CellType::HTML, // Status uses HTML for badges
ColumnType::CUSTOM => CellType::HTML, // Custom can be HTML
};
return new TableColumn(
key: $this->key,
header: $this->header,
cssClass: $this->cssClass,
formatter: $this->formatter,
sortable: $this->sortable,
width: $this->width,
defaultType: $cellType
);
}
private function validateTextValue(mixed $value): void
{
if ($value !== null && ! is_string($value) && ! is_numeric($value)) {
throw new \InvalidArgumentException(
"Text column '{$this->key}' expects string or numeric value, got " . gettype($value)
);
}
}
private function validateNumericValue(mixed $value): void
{
if ($value !== null && ! is_numeric($value)) {
throw new \InvalidArgumentException(
"Numeric column '{$this->key}' expects numeric value, got " . gettype($value)
);
}
}
private function validateBooleanValue(mixed $value): void
{
if ($value !== null && ! is_bool($value)) {
throw new \InvalidArgumentException(
"Boolean column '{$this->key}' expects boolean value, got " . gettype($value)
);
}
}
private function validateDateValue(mixed $value): void
{
if ($value !== null && ! ($value instanceof \DateTimeInterface) && ! is_string($value)) {
throw new \InvalidArgumentException(
"Date column '{$this->key}' expects DateTimeInterface or string, got " . gettype($value)
);
}
}
private function validateStatusValue(mixed $value): void
{
if ($value !== null && ! is_array($value)) {
throw new \InvalidArgumentException(
"Status column '{$this->key}' expects array with status data, got " . gettype($value)
);
}
if (is_array($value)) {
$requiredKeys = ['status_text', 'status_class'];
foreach ($requiredKeys as $key) {
if (! array_key_exists($key, $value)) {
throw new \InvalidArgumentException(
"Status column '{$this->key}' requires '{$key}' in status array"
);
}
}
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\ValueObjects;
enum ColumnType: string
{
case TEXT = 'text';
case NUMERIC = 'numeric';
case BOOLEAN = 'boolean';
case DATE = 'date';
case STATUS = 'status';
case CUSTOM = 'custom';
/**
* Get appropriate CSS class for column type
*/
public function getCssClass(): string
{
return match ($this) {
self::TEXT => 'text-col',
self::NUMERIC => 'numeric-col',
self::BOOLEAN => 'boolean-col',
self::DATE => 'date-col',
self::STATUS => 'status-col',
self::CUSTOM => 'custom-col',
};
}
/**
* Get default alignment for column type
*/
public function getDefaultAlignment(): string
{
return match ($this) {
self::TEXT => 'left',
self::NUMERIC => 'right',
self::BOOLEAN => 'center',
self::DATE => 'left',
self::STATUS => 'center',
self::CUSTOM => 'left',
};
}
/**
* Check if column type supports sorting
*/
public function supportsSorting(): bool
{
return match ($this) {
self::TEXT, self::NUMERIC, self::DATE => true,
self::BOOLEAN, self::STATUS, self::CUSTOM => false,
};
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\ValueObjects;
use App\Framework\View\Table\TableCell;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableRow;
final readonly class TableRowData
{
/** @param mixed[] $values */
public function __construct(
public array $values,
public ?string $cssClass = null,
public ?string $id = null
) {
}
/**
* Create TableRowData from values matching column order
*
* @param ColumnDefinition[] $columns
* @param mixed ...$values
*/
public static function fromValues(array $columns, mixed ...$values): self
{
if (count($values) !== count($columns)) {
throw new \InvalidArgumentException(
'Value count must match column count'
);
}
// Create associative array with column keys
$indexedValues = [];
foreach ($columns as $index => $column) {
$indexedValues[$column->key] = $values[$index];
}
return new self($indexedValues);
}
/**
* Create TableRowData from associative array
*/
public static function fromArray(array $data, ?string $cssClass = null, ?string $id = null): self
{
return new self($data, $cssClass, $id);
}
/**
* Add CSS class to this row
*/
public function withCssClass(string $cssClass): self
{
return new self($this->values, $cssClass, $this->id);
}
/**
* Add ID to this row
*/
public function withId(string $id): self
{
return new self($this->values, $this->cssClass, $id);
}
/**
* Get value for specific column
*/
public function getValue(string $columnKey): mixed
{
return $this->values[$columnKey] ?? null;
}
/**
* Check if row has value for column
*/
public function hasValue(string $columnKey): bool
{
return array_key_exists($columnKey, $this->values);
}
/**
* Get all values as associative array
*/
public function toArray(): array
{
return $this->values;
}
/**
* Convert to TableRow for use in existing Table class
*
* @param TableColumn[] $columns
*/
public function toTableRow(array $columns): TableRow
{
$cells = [];
foreach ($columns as $column) {
$value = $this->getValue($column->key);
// Apply column formatter if available
if ($column->formatter !== null) {
$formattedValue = $column->formatter->format($value);
} else {
$formattedValue = $this->formatDefaultValue($value);
}
$cells[] = new TableCell(
value: $formattedValue,
cssClass: $column->cssClass,
type: $column->defaultType // Pass the CellType so HTML isn't escaped
);
}
return new TableRow(
cells: $cells,
cssClass: $this->cssClass
);
}
/**
* Default value formatting when no custom formatter is provided
*/
private function formatDefaultValue(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? 'Yes' : 'No';
}
if (is_array($value) || is_object($value)) {
return json_encode($value) ?: '[Complex Value]';
}
return (string) $value;
}
/**
* Validate this row against column definitions
*
* @param ColumnDefinition[] $columns
*/
public function validateAgainstColumns(array $columns): void
{
foreach ($columns as $column) {
$value = $this->getValue($column->key);
$column->validateValue($value);
}
}
}

View File

@@ -27,24 +27,37 @@ final class TemplateProcessor
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...");
$processedHtml = $this->processString($html, $context);
// Skip DOM processing if no DOM processors configured
if (empty($this->domProcessors)) {
return $this->processString($html, $context);
error_log("No DOM processors - string processing complete");
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));
}
$dom = $this->domPipeline->process($context, $html);
error_log("Starting DOM processing AFTER string processing...");
$dom = $this->domPipeline->process($context, $processedHtml);
$processedHtml = $component
$finalHtml = $component
? $this->extractBodyContent($dom->document)
: $dom->document->saveHTML();
return $this->processString($processedHtml, $context);
error_log("Template processing complete for: " . $context->template);
return $finalHtml;
}
public function __debugInfo(): ?array
@@ -59,12 +72,21 @@ final class TemplateProcessor
private function processString(string $html, RenderContext $context): string
{
error_log("TemplateProcessor::processString started");
// 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");
}
error_log("TemplateProcessor::processString completed");
return $html;
}

View File

@@ -18,13 +18,16 @@ 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\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
{
@@ -38,11 +41,13 @@ final readonly class TemplateRendererInitializer
public function __invoke(): TemplateRenderer
{
$doms = [
ComponentProcessor::class,
LayoutTagProcessor::class,
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,
IfProcessor::class,
ForProcessor::class,
AssetInjector::class,
CommentStripProcessor::class,
RemoveEmptyLinesProcessor::class,
@@ -51,13 +56,13 @@ final readonly class TemplateRendererInitializer
];
$strings = [
PlaceholderReplacer::class,
PlaceholderReplacer::class, // PlaceholderReplacer handles simple {{ }} replacements
#SingleLineHtmlProcessor::class,
VoidElementsSelfClosingProcessor::class,
# CsrfReplaceProcessor::class, // DEACTIVATED - FormDataResponseMiddleware handles form processing
];
$domImplementations = [];
/*$domImplementations = [];
foreach ($this->results->interfaces->get(DomProcessor::class) as $className) {
$domImplementations[] = $className->getFullyQualified();
}
@@ -65,13 +70,7 @@ final readonly class TemplateRendererInitializer
$stringImplementations = [];
foreach ($this->results->interfaces->get(StringProcessor::class) as $className) {
$stringImplementations[] = $className->getFullyQualified();
}
$templateProcessor = new TemplateProcessor(
[ComponentProcessor::class, ...$domImplementations],
$stringImplementations,
$this->container,
);
}*/
$templateProcessor = new TemplateProcessor($doms, $strings, $this->container);
@@ -81,10 +80,19 @@ final readonly class TemplateRendererInitializer
$pathProvider = $this->container->get(PathProvider::class);
/** @var Cache $cache */
$cache = $this->container->get(Cache::class);
/** @var PerformanceService $performanceService */
$performanceService = $this->container->get(PerformanceService::class);
$loader = new TemplateLoader(pathProvider: $pathProvider, cache: $cache, discoveryRegistry: $this->results/*, templates: $templates*/);
// Define caching state centrally
$cacheEnabled = false; // Keep caching disabled while debugging template processing
$loader = new TemplateLoader(
pathProvider: $pathProvider,
cache: $cache,
discoveryRegistry: $this->results,
cacheEnabled: $cacheEnabled // Pass cache state to loader
);
$this->container->singleton(TemplateLoader::class, $loader);
@@ -93,6 +101,7 @@ final readonly class TemplateRendererInitializer
performanceService: $performanceService,
processor: $templateProcessor,
cache: $cache,
cacheEnabled: $cacheEnabled, // Use same cache state for Engine
);
}
}

View File

@@ -0,0 +1,43 @@
<?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

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\ValueObjects;
final readonly class FormElement implements HtmlElement
{
public function __construct(
public HtmlTag $tag,
public HtmlAttributes $attributes,
public string $content = ''
) {
if (!$this->tag->isFormElement()) {
throw new \InvalidArgumentException("Tag {$this->tag} is not a form element");
}
}
public static function form(string $action = '', string $method = 'post', ?string $htmlId = null): self
{
$attributes = HtmlAttributes::empty()
->withAction($action)
->withMethod($method);
if ($htmlId) {
$attributes = $attributes->withId($htmlId);
}
return new self(HtmlTag::form(), $attributes);
}
public static function input(string $type, string $name, string $value = ''): self
{
$attributes = HtmlAttributes::empty()
->withType($type)
->withName($name);
if ($value !== '') {
$attributes = $attributes->withValue($value);
}
return new self(HtmlTag::input(), $attributes);
}
public static function hiddenInput(string $name, string $value): self
{
return self::input('hidden', $name, $value);
}
public static function textInput(string $name, string $value = ''): self
{
return self::input('text', $name, $value);
}
public static function emailInput(string $name, string $value = ''): self
{
return self::input('email', $name, $value);
}
public static function passwordInput(string $name): self
{
return self::input('password', $name);
}
public static function fileInput(string $name): self
{
return self::input('file', $name);
}
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);
}
public function withAttribute(string $name, ?string $value = null): self
{
return new self($this->tag, $this->attributes->with($name, $value), $this->content);
}
public function withRequired(): self
{
return new self($this->tag, $this->attributes->withRequired(), $this->content);
}
public function withDisabled(): self
{
return new self($this->tag, $this->attributes->withDisabled(), $this->content);
}
public function withClass(string $class): self
{
return new self($this->tag, $this->attributes->withClass($class), $this->content);
}
public function withId(string $id): self
{
return new self($this->tag, $this->attributes->withId($id), $this->content);
}
public function __toString(): string
{
$tagName = (string) $this->tag;
$attributesString = (string) $this->attributes;
$attributesPart = $attributesString ? " {$attributesString}" : '';
if ($this->tag->isSelfClosing()) {
return "<{$tagName}{$attributesPart}>";
}
return "<{$tagName}{$attributesPart}>{$this->content}</{$tagName}>";
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\ValueObjects;
use App\Framework\Http\Session\FormIdGenerator;
final readonly class FormId
{
public function __construct(
public string $value
) {
if (empty(trim($value))) {
throw new \InvalidArgumentException('FormId cannot be empty');
}
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');
}
}
public static function generate(FormIdGenerator $generator, string $route = '/', string $method = 'post'): self
{
// Use the existing FormIdGenerator logic
$formId = $generator->generateFormId($route, $method);
return new self($formId);
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function isValid(FormIdGenerator $generator): bool
{
return $generator->isValidFormId($this);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\ValueObjects;
final readonly class HtmlAttributes
{
/**
* @param array<string, string|null> $attributes
*/
public function __construct(
public array $attributes = []
) {}
public static function empty(): self
{
return new self([]);
}
public static function fromArray(array $attributes): self
{
return new self($attributes);
}
public function with(string $name, ?string $value = null): self
{
return new self([...$this->attributes, $name => $value]);
}
public function withFlag(string $name): self
{
return $this->with($name, null);
}
public function withId(string $id): self
{
return $this->with('id', $id);
}
public function withClass(string $class): self
{
return $this->with('class', $class);
}
public function withType(string $type): self
{
return $this->with('type', $type);
}
public function withName(string $name): self
{
return $this->with('name', $name);
}
public function withValue(string $value): self
{
return $this->with('value', $value);
}
public function withAction(string $action): self
{
return $this->with('action', $action);
}
public function withMethod(string $method): self
{
return $this->with('method', $method);
}
public function withDisabled(): self
{
return $this->withFlag('disabled');
}
public function withRequired(): self
{
return $this->withFlag('required');
}
public function withChecked(): self
{
return $this->withFlag('checked');
}
public function withReadonly(): self
{
return $this->withFlag('readonly');
}
public function withSelected(): self
{
return $this->withFlag('selected');
}
public function get(string $name): ?string
{
return $this->attributes[$name] ?? null;
}
public function has(string $name): bool
{
return array_key_exists($name, $this->attributes);
}
public function isFlag(string $name): bool
{
return $this->has($name) && $this->get($name) === null;
}
public function __toString(): string
{
if (empty($this->attributes)) {
return '';
}
$parts = [];
foreach ($this->attributes as $name => $value) {
if ($value === null) {
// Boolean/flag attribute without value
$parts[] = $name;
} else {
// Attribute with value
$escapedValue = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
$parts[] = "{$name}=\"{$escapedValue}\"";
}
}
return implode(' ', $parts);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
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

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\ValueObjects;
final readonly class HtmlTag
{
public function __construct(
public TagName $name
) {}
public static function div(): self
{
return new self(TagName::DIV);
}
public static function form(): self
{
return new self(TagName::FORM);
}
public static function input(): self
{
return new self(TagName::INPUT);
}
public static function button(): self
{
return new self(TagName::BUTTON);
}
public static function label(): self
{
return new self(TagName::LABEL);
}
public static function textarea(): self
{
return new self(TagName::TEXTAREA);
}
public static function select(): self
{
return new self(TagName::SELECT);
}
public static function option(): self
{
return new self(TagName::OPTION);
}
public function isSelfClosing(): bool
{
return $this->name->isSelfClosing();
}
public function isFormElement(): bool
{
return $this->name->isFormElement();
}
public function __toString(): string
{
return $this->name->value;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\ValueObjects;
final readonly class StandardHtmlElement implements HtmlElement
{
public function __construct(
public HtmlTag $tag,
public HtmlAttributes $attributes = new HtmlAttributes(),
public string $content = ''
) {}
public static function create(TagName $tagName, ?HtmlAttributes $attributes = null, string $content = ''): self
{
return new self(
new HtmlTag($tagName),
$attributes ?? HtmlAttributes::empty(),
$content
);
}
public function withAttributes(HtmlAttributes $attributes): self
{
return new self($this->tag, $attributes, $this->content);
}
public function withContent(string $content): self
{
return new self($this->tag, $this->attributes, $content);
}
public function withAttribute(string $name, ?string $value = null): self
{
return $this->withAttributes($this->attributes->with($name, $value));
}
public function __toString(): string
{
$tagName = (string) $this->tag;
$attributesString = (string) $this->attributes;
$attributesPart = $attributesString ? " {$attributesString}" : '';
if ($this->tag->isSelfClosing()) {
return "<{$tagName}{$attributesPart}>";
}
return "<{$tagName}{$attributesPart}>{$this->content}</{$tagName}>";
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\ValueObjects;
enum TagName: string
{
case DIV = 'div';
case FORM = 'form';
case INPUT = 'input';
case BUTTON = 'button';
case LABEL = 'label';
case TEXTAREA = 'textarea';
case SELECT = 'select';
case OPTION = 'option';
case SPAN = 'span';
case P = 'p';
case H1 = 'h1';
case H2 = 'h2';
case H3 = 'h3';
case H4 = 'h4';
case H5 = 'h5';
case H6 = 'h6';
case A = 'a';
case IMG = 'img';
case UL = 'ul';
case OL = 'ol';
case LI = 'li';
case TABLE = 'table';
case TR = 'tr';
case TD = 'td';
case TH = 'th';
case THEAD = 'thead';
case TBODY = 'tbody';
case TFOOT = 'tfoot';
public function isSelfClosing(): bool
{
return match ($this) {
self::INPUT, self::IMG => true,
default => false
};
}
public function isFormElement(): bool
{
return match ($this) {
self::FORM, self::INPUT, self::BUTTON, self::LABEL, self::TEXTAREA, self::SELECT, self::OPTION => true,
default => false
};
}
}

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Basic Component Card</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Title</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1,2 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}✅ Success Card</h3><div><span class="admin-table__status admin-table__status--success"><span class="status-indicator status-indicator--success"></span>PASS
</span></div></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Basic Component Card</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1,2 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}✅ Success Card</h3><div><span class="admin-table__status admin-table__status--success"><span class="status-indicator status-indicator--success"></span>PASS
</span></div></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Basic Component Card</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1,2 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}✅ Success Card</h3><div><span class="admin-table__status admin-table__status--success"><span class="status-indicator status-indicator--success"></span>PASS
</span></div></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Title</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1,2 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}✅ Success Card</h3><div><span class="admin-table__status admin-table__status--success"><span class="status-indicator status-indicator--success"></span>PASS
</span></div></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Basic Component Card</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Title</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1 @@
<div class="admin-card {{ status ? 'status-card status-card--' + status : '' }}"><div class="admin-card__header"><h3 class="admin-card__title">{{ icon }}Title</h3></div><div class="admin-card__content"><slot></slot></div></div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Total Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Total Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Total Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,3 @@
<div class="admin-card metric-card"><div class="metric-card__value" style="{{ color ? 'color: var(--' + color + ')' : '' }}">1,234</div><div class="metric-card__label">Total Users</div>
{{ change ? '<div class="metric-card__change metric-card__change--' + change_type + '">' + change + '</div>' : '' }}
</div>

View File

@@ -0,0 +1,12 @@
Database
&lt;span class=&quot;admin-table__status {{ check.statusClass }}&quot;&gt;
&lt;span class=&quot;status-indicator status-indicator--{{ check.statusIcon }}&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;admin-table__status admin-table__status--success&quot;&gt;&lt;span class=&quot;status-indicator status-indicator--success&quot;&gt;&lt;/span&gt;healthy&lt;/span&gt;
&lt;/span&gt;
Connection successful
8ms

View File

@@ -0,0 +1,7 @@
Database
healthy
Connection successful
8ms

View File

@@ -0,0 +1,7 @@
Redis
healthy
Cache operational
5ms

View File

@@ -0,0 +1,3 @@
<html><head><link rel="stylesheet" href="/assets/css/main-DLVw97vA.css"><script src="/assets/js/main-CyVTPjIx.js" type="module"></script></head><body>
{{ cellContent }}
</body></html>

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