feat(Production): Complete production deployment infrastructure

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

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

View File

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

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\Contracts\StaticComponent;
use App\Framework\View\Dom\ElementNode;
use App\Framework\View\Dom\Node;
use App\Framework\View\Dom\TextNode;
#[ComponentName('admin-header')]
final readonly class AdminHeader implements StaticComponent
{
private string $pageTitle;
private bool $showSearch;
public function __construct(
string $content = '',
array $attributes = []
) {
// Extract attributes with defaults
$this->pageTitle = $attributes['page-title'] ?? 'Admin';
$this->showSearch = isset($attributes['show-search'])
? filter_var($attributes['show-search'], FILTER_VALIDATE_BOOLEAN)
: true;
}
public function getRootNode(): Node
{
$header = new ElementNode('header');
$header->setAttribute('class', 'admin-header');
$header->setAttribute('role', 'banner');
// Use TextNode for HTML content
$content = new TextNode($this->buildHeaderContent());
$header->appendChild($content);
return $header;
}
private function buildHeaderContent(): string
{
$searchHtml = $this->showSearch ? $this->buildSearchBar() : '';
return <<<HTML
<h1 class="admin-header__title">{$this->pageTitle}</h1>
{$searchHtml}
<div class="admin-header__actions">
<button
class="admin-action-btn"
aria-label="Notifications"
data-dropdown-trigger="notifications"
>
<span class="admin-action-btn__icon">🔔</span>
<span class="admin-action-btn__badge admin-action-btn__badge--count">3</span>
</button>
<button
class="admin-theme-toggle"
data-theme-toggle
aria-label="Toggle theme"
title="Toggle dark/light mode"
>
<span class="admin-theme-toggle__icon" data-theme-icon="light" style="display: none;">☀️</span>
<span class="admin-theme-toggle__icon" data-theme-icon="dark" style="display: none;">🌙</span>
<span class="admin-theme-toggle__label">Theme</span>
</button>
<div class="admin-user-menu" data-dropdown>
<button class="admin-user-menu__trigger" data-dropdown-trigger="user-menu" aria-haspopup="true" aria-expanded="false">
<div class="admin-user-menu__avatar" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.875rem;">A</div>
<span class="admin-user-menu__name">Admin</span>
<span class="admin-user-menu__chevron">▼</span>
</button>
<ul class="admin-user-menu__dropdown" role="menu">
<li class="admin-user-menu__item">
<a href="/admin/profile" class="admin-user-menu__link" role="menuitem">
<span class="admin-user-menu__icon">👤</span>
<span>Profile</span>
</a>
</li>
<li class="admin-user-menu__item">
<a href="/admin/settings" class="admin-user-menu__link" role="menuitem">
<span class="admin-user-menu__icon">⚙️</span>
<span>Settings</span>
</a>
</li>
<li class="admin-user-menu__item">
<a href="/logout" class="admin-user-menu__link" role="menuitem">
<span class="admin-user-menu__icon">🚪</span>
<span>Logout</span>
</a>
</li>
</ul>
</div>
</div>
HTML;
}
private function buildSearchBar(): string
{
return <<<HTML
<div class="admin-header__search">
<div class="admin-search">
<label for="admin-search-input" class="admin-visually-hidden">Search admin</label>
<input
type="search"
id="admin-search-input"
class="admin-search__input"
placeholder="Search..."
aria-label="Search"
/>
<span class="admin-search__icon" aria-hidden="true">🔍</span>
</div>
</div>
HTML;
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\Contracts\StaticComponent;
use App\Framework\View\Dom\ElementNode;
use App\Framework\View\Dom\Node;
use App\Framework\View\Dom\TextNode;
#[ComponentName('admin-sidebar')]
final readonly class AdminSidebar implements StaticComponent
{
private string $title;
private string $logoUrl;
private string $currentPath;
public function __construct(
string $content = '',
array $attributes = []
) {
// Extract attributes with defaults
$this->title = $attributes['title'] ?? 'Admin Panel';
$this->logoUrl = $attributes['logo-url'] ?? '/admin';
$this->currentPath = $attributes['current-path'] ?? '/admin';
}
public function getRootNode(): Node
{
$nav = new ElementNode('nav');
$nav->setAttribute('class', 'admin-sidebar');
$nav->setAttribute('role', 'navigation');
$nav->setAttribute('aria-label', 'Admin navigation');
// Use TextNode for HTML content (will be parsed by HtmlRenderer)
$content = new TextNode($this->buildSidebarContent());
$nav->appendChild($content);
return $nav;
}
private function buildSidebarContent(): string
{
return <<<HTML
<div class="admin-sidebar__header">
<a href="{$this->logoUrl}" class="admin-sidebar__logo-link">
<span class="admin-sidebar__title">{$this->title}</span>
</a>
</div>
<nav class="admin-nav">
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">Dashboard</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin" class="admin-nav__link" {$this->getActiveState('/admin')}>
<span class="admin-nav__icon">📊</span>
<span>Overview</span>
</a>
</li>
</ul>
</div>
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">System</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/infrastructure/cache" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/cache')}>
<span class="admin-nav__icon">💾</span>
<span>Cache</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/infrastructure/logs" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/logs')}>
<span class="admin-nav__icon">📝</span>
<span>Logs</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/infrastructure/migrations" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/migrations')}>
<span class="admin-nav__icon">🔄</span>
<span>Migrations</span>
</a>
</li>
</ul>
</div>
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">Content</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/images" class="admin-nav__link" {$this->getActiveState('/admin/images')}>
<span class="admin-nav__icon">🖼️</span>
<span>Images</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/users" class="admin-nav__link" {$this->getActiveState('/admin/users')}>
<span class="admin-nav__icon">👥</span>
<span>Users</span>
</a>
</li>
</ul>
</div>
</nav>
<div class="admin-sidebar__footer">
<div class="admin-sidebar__user">
<div class="admin-sidebar__avatar" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.875rem;">A</div>
<div class="admin-sidebar__user-info">
<span class="admin-sidebar__user-name">Admin User</span>
<span class="admin-sidebar__user-role">Administrator</span>
</div>
</div>
</div>
HTML;
}
private function getActiveState(string $path): string
{
return $path === $this->currentPath ? 'aria-current="page"' : '';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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