chore: lots of changes

This commit is contained in:
2025-05-24 07:09:22 +02:00
parent 77ee769d5e
commit 899227b0a4
178 changed files with 5145 additions and 53 deletions

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Router\ActionResult;
class IrkEndpoint
{
#[Route(method: 'GET', path: '/irk-impressum')]
public function impressum()
{
return;
}
#[Route(method: 'GET', path: '/irk-datenschutz')]
public function datenschutz()
{
return;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Application\EPK;
use App\Framework\Attributes\Route;
use App\Framework\Router\ActionResult;
use App\Framework\Router\ResultType;
class ShowEpk
{
#[Route(path: '/epk')]
public function epk(): ActionResult
{
return new ActionResult(ResultType::Html, 'epk', ['text' => 'EPK!']);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Application\Website;
use App\Framework\Attributes\Route;
use App\Framework\Http\Request;
use App\Framework\Router\ActionResult;
use App\Framework\Router\ResultType;
class ShowHome
{
#[Route(method: 'GET', path: '/')]
public function __invoke(): ActionResult
{
return new ActionResult(
ResultType::Html,
'test',
['name' => 'Michael','title' => 'HalloWeltTitel'],
);
}
#[Route(method: 'GET', path: '/epk')]
public function impressum(string $test = 'hallo'): ActionResult
{
return new ActionResult(
ResultType::Plain,
'test',
['text' => 'EPK!'],
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Application\Website;
use App\Framework\Attributes\Route;
use App\Framework\Router\ActionResult;
use App\Framework\Router\ResultType;
class ShowImpressum
{
#[Route(method: 'GET', path: '/impressum')]
public function impressum(string $test = 'hallo'): ActionResult
{
return new ActionResult(
ResultType::Html,
'impressum',
['text' => 'Hallo Welt!'],
);
}
#[Route(method: 'GET', path: '/datenschutz')]
public function datenschutz(string $test = 'hallo'): ActionResult
{
return new ActionResult(
ResultType::Html,
'impressum',
['title' => 'Datenschutz!'],
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use Attribute;
#[Attribute(\Attribute::TARGET_METHOD, \Attribute::IS_REPEATABLE)]
class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
) {
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Framework\Attributes;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class Singleton
{
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
interface AttributeMapper
{
public function getAttributeClass(): string;
/**
* @param object $reflectionTarget ReflectionClass|ReflectionMethod
* @param object $attributeInstance
* @return array|null
*/
public function map(object $reflectionTarget, object $attributeInstance): ?array;
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
use Exception;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use ReflectionMethod;
class Discovery
{
/** @var array<string, AttributeMapper> */
private array $mapperMap;
private string $cacheFile;
/**
* @param AttributeMapper[] $mappers
*/
public function __construct(AttributeMapper ...$mappers)
{
$this->mapperMap = [];
foreach ($mappers as $mapper) {
$this->mapperMap[$mapper->getAttributeClass()] = $mapper;
}
$this->cacheFile = __DIR__ .'/../../../cache/discovery.cache.php';
}
public function discover(string $directory): array
{
$hash = md5(realpath($directory));
$this->cacheFile = __DIR__ ."/../../../cache/discovery_{$hash}.cache.php";
$latestMTime = $this->getLatestMTime($directory);
$data = $this->loadCache($latestMTime);
if ($data !== null) {
return $data;
}
$results = [];
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
foreach ($rii as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') {
continue;
}
try {
$className = $this->getClassNameFromFile($file->getPathname());
if (! $className || ! class_exists($className)) {
continue;
}
$refClass = new ReflectionClass($className);
$this->discoverClass($refClass, $results);
} catch (Exception $e) {
error_log("Discovery Warning: Fehler in Datei {$file->getPathname()}: " . $e->getMessage());
}
}
$results['__discovery_mtime'] = $latestMTime;
$this->storeCache($results);
return $results;
}
private function discoverClass(ReflectionClass $refClass, array &$results): void
{
$this->processAttributes($refClass, $results);
foreach ($refClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
$this->processAttributes($method, $results);
}
}
private function processAttributes(ReflectionClass|ReflectionMethod $ref, array &$results): void
{
foreach ($ref->getAttributes() as $attribute) {
$attrName = $attribute->getName();
if (! isset($this->mapperMap[$attrName])) {
continue;
}
$mapped = $this->mapperMap[$attrName]->map($ref, $attribute->newInstance());
if ($mapped !== null && $ref instanceof ReflectionMethod) {
$params = [];
foreach ($ref->getParameters() as $param) {
$paramData = [
'name' => $param->getName(),
'type' => $param->getType()?->getName(),
'isBuiltin' => $param->getType()?->isBuiltin() ?? false,
'hasDefault' => $param->isDefaultValueAvailable(),
'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
'attributes' => array_map(
fn ($a) => $a->getName(),
$param->getAttributes()
),
];
$params[] = $paramData;
}
$mapped['parameters'] = $params;
}
if ($mapped !== null) {
$results[$attrName][] = $mapped;
}
}
}
public function getLatestMTime(string $directory): int
{
$maxMTime = 0;
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
foreach ($rii as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$mtime = $file->getMTime();
if ($mtime > $maxMTime) {
$maxMTime = $mtime;
}
}
}
return $maxMTime;
}
private function loadCache(int $latestMTime): ?array
{
if (! file_exists($this->cacheFile)) {
return null;
}
$data = include $this->cacheFile;
if (
! is_array($data)
|| ! isset($data['__discovery_mtime'])
|| $data['__discovery_mtime'] !== $latestMTime
) {
return null;
}
unset($data['__discovery_mtime']);
return $data;
}
private function storeCache(array $results): void
{
$export = var_export($results, true);
file_put_contents($this->cacheFile, "<?php\nreturn {$export};\n");
}
private function getClassNameFromFile(string $file): ?string
{
$contents = file_get_contents($file);
if (
preg_match('#namespace\s+([^;]+);#', $contents, $nsMatch)
&& preg_match('/class\s+(\w+)/', $contents, $classMatch)
) {
return trim($nsMatch[1]) . '\\' . trim($classMatch[1]);
}
return null;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
final readonly class DynamicRoute implements Route
{
public function __construct(
public string $regex,
public array $paramNames,
public string $controller,
public string $method,
public array $parameters
) {
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
class PhpObjectExporter
{
/**
* Eigene Export-Funktion, die rekursiv ein PHP-Array mit echten Konstruktoraufrufen exportiert.
* (Variante unten für Standardfälle ausreichend! Für verschachtelte/nicht-indizierte Arrays ggf. anpassen.)
*/
public static function export($value)
{
if (is_array($value)) {
$exported = [];
foreach ($value as $key => $v) {
$exported[] = var_export($key, true) . ' => ' . self::export($v);
}
return '[' . implode(",\n", $exported) . ']';
}
if ($value instanceof StaticRoute) {
return "new StaticRoute("
. var_export($value->controller, true) . ', '
. var_export($value->action, true) . ', '
. self::export($value->params, true)
. ")";
}
if ($value instanceof DynamicRoute) {
return "new DynamicRoute("
. var_export($value->regex, true) . ', '
. self::export($value->parameters, true) . ', '
. var_export($value->controller, true) . ', '
. var_export($value->method, true) . ', '
. var_export($value->parameters, true)
. ")";
}
// Fallback für skalare Werte
return var_export($value, true);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
interface Route
{
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
class RouteCache
{
public function __construct(
private string $cacheFile
) {
}
public function save(array $routes)
{
$phpExport = '<?php' . PHP_EOL;
$phpExport .= 'use App\Framework\Core\StaticRoute;' . PHP_EOL;
$phpExport .= 'use App\Framework\Core\DynamicRoute;' . PHP_EOL;
$phpExport .= 'return ' . PhpObjectExporter::export($routes) . ';' . PHP_EOL;
file_put_contents($this->cacheFile, $phpExport);
}
public function load(): array
{
if (! file_exists($this->cacheFile)) {
throw new \RuntimeException("Route cache file not found: {$this->cacheFile}");
}
$data = include $this->cacheFile;
if (! is_array($data)) {
throw new \RuntimeException("Invalid route cache format.");
}
return $data;
}
public function isFresh(int $expectedMTime): bool
{
// Optional: prüfe eine Timestamp-Struktur etc.
if (! file_exists($this->cacheFile)) {
return false;
}
// Eigenes Format prüfen? Datei-mtime prüfen? Optional!
return true;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
final class RouteCompiler
{
/**
* @param array<int, array{method: string, path: string, controller: class-string, handler: string}> $routes
* @return array<string, array{static: array<string, array>, dynamic: array<int, array{regex: string, params: array, handler: array}>}>
*/
public function compile(array $routes): array
{
$compiled = [];
foreach ($routes as $route) {
$method = strtoupper($route['http_method']);
$path = $route['path'];
$compiled[$method] ??= ['static' => [], 'dynamic' => []];
if (! str_contains($path, '{')) {
// Statische Route
$compiled[$method]['static'][$path] = new StaticRoute(
$route['class'],
$route['method'],
$route['parameters']
);
} else {
// Dynamische Route
$regex = $this->convertPathToRegex($path, $paramNames);
$compiled[$method]['dynamic'][] = new DynamicRoute(
$regex,
$paramNames,
$route['class'],
$route['method'],
$route['parameters']
);
}
}
return $compiled;
}
/**
* Konvertiert zB. /user/{id}/edit → ~^/user/([^/]+)/edit$~ und gibt ['id'] als Parameternamen zurück.
*
* @param string $path
* @param array<string> &$paramNames
* @return string
*/
private function convertPathToRegex(string $path, array &$paramNames): string
{
$paramNames = [];
$regex = preg_replace_callback('#\{(\w+)\}#', function ($matches) use (&$paramNames) {
$paramNames[] = $matches[1];
return '([^/]+)';
}, $path);
return '~^' . $regex . '$~';
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Attributes\Route;
use ReflectionMethod;
final readonly class RouteMapper implements AttributeMapper
{
public function getAttributeClass(): string
{
return Route::class;
}
public function map(object $reflectionTarget, object $attributeInstance): ?array
{
if (! $reflectionTarget instanceof ReflectionMethod || ! $attributeInstance instanceof Route) {
return null;
}
return [
'class' => $reflectionTarget->getDeclaringClass()->getName(),
'method' => $reflectionTarget->getName(),
'http_method' => $attributeInstance->method,
'path' => $attributeInstance->path,
];
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
class StaticRoute implements Route
{
public function __construct(
public string $controller,
public string $action,
public array $params
) {
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI;
use App\Framework\Attributes\Singleton;
class Container
{
private array $singletons = [];
public function __construct(private array $definitions)
{
}
public function get(string $class): object
{
if (isset($this->singletons[$class])) {
return $this->singletons[$class];
}
$reflection = new \ReflectionClass($class);
$constructor = $reflection->getConstructor();
$dependencies = [];
if ($constructor !== null) {
foreach ($constructor->getParameters() as $param) {
$type = $param->getType();
if (!$type || $type->isBuiltin()) {
throw new \RuntimeException("Cannot resolve parameter {$param->getName()}");
}
$dependencies[] = $this->get($type->getName());
}
}
$instance = $reflection->newInstanceArgs($dependencies);
if ($reflection->getAttributes(Singleton::class)) {
$this->singletons[$class] = $instance;
}
return $instance;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Framework\ErrorHandling;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\ResponseEmitter;
use App\Framework\Http\Status;
use JetBrains\PhpStorm\NoReturn;
use Throwable;
final class ErrorHandler
{
public static function register(ResponseEmitter $emitter): void
{
set_exception_handler(function (Throwable $exception) use ($emitter) {
self::handleException($exception, $emitter);
});
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($emitter) {
$exception = new \ErrorException($errstr, 0, $errno, $errfile, $errline);
self::handleException($exception, $emitter);
});
register_shutdown_function(function () use ($emitter) {
$error = error_get_last();
if ($error) {
$exception = new \ErrorException(
$error['message'],
0,
$error['type'],
$error['file'],
$error['line']
);
self::handleException($exception, $emitter);
}
});
}
#[NoReturn]
private static function handleException(Throwable $e, ResponseEmitter $emitter): void
{
$isDebug = $_ENV['APP_DEBUG'] ?? false;
$status = Status::INTERNAL_SERVER_ERROR ?? 500;
$message = $isDebug
? sprintf("Fehler: %s in %s:%d\n\n%s", $e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString())
: "Es ist ein interner Fehler aufgetreten.";
$headers = new Headers()->with('Content-Type', 'text/plain; charset=utf-8');
$response = new HttpResponse(
status: $status,
headers: $headers,
body: $message
);
// Sende die Fehlermeldung (so früh wie möglich!)
$emitter->emit($response);
exit(1);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final readonly class Cookie
{
public function __construct(
public string $name,
public string $value,
public ?int $expires = null,
public string $path = '/',
public ?string $domain = '',
public bool $secure = false,
public bool $httpOnly = false,
public ?string $sameSite = 'Lax'
) {
}
public function toHeaderString(): string
{
$cookie = urlencode($this->name) . '=' . urlencode($this->value);
if ($this->expires !== null) {
$cookie .= '; Expires=' . gmdate('D, d-M-Y H:i:s T', $this->expires);
}
if ($this->path) {
$cookie .= '; Path=' . $this->path;
}
if ($this->domain) {
$cookie .= '; Domain=' . $this->domain;
}
if ($this->secure) {
$cookie .= '; Secure';
}
if ($this->httpOnly) {
$cookie .= '; HttpOnly';
}
if ($this->sameSite) {
$cookie .= '; SameSite=' . $this->sameSite;
}
return $cookie;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
class Cookies
{
/** @var array<string, Cookie> */
private array $cookies = [];
public function __construct(array $rawCookies = [])
{
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Framework\Http;
final class Headers
{
/**
* Struktur:
* [
* 'content-type' => ['Content-Type', ['text/html']],
* 'set-cookie' => ['Set-Cookie', ['a=1', 'b=2']],
* ]
*/
/**
* @var array<string, array{string, string[]}>
* Struktur: 'normalized-lower-name' => [Original-Name, [Wert1, Wert2, ...]]
*/
public function __construct(
private readonly array $headers = []
) {}
public function with(string $name, string|array $value): self
{
$key = strtolower($name);
$original = $this->normalizeName($name);
$values = is_array($value) ? array_values($value) : [$value];
$new = $this->headers;
$new[$key] = [$original, $values];
return new self($new);
}
public function withAdded(string $name, string $value): self
{
$key = strtolower($name);
$original = $this->normalizeName($name);
$new = $this->headers;
if (!isset($new[$key])) {
$new[$key] = [$original, [$value]];
} else {
$new[$key][1][] = $value;
}
return new self($new);
}
public function without(string $name): self
{
$key = strtolower($name);
$new = $this->headers;
unset($new[$key]);
return new self($new);
}
public function get(string $name): ?array
{
return $this->headers[strtolower($name)][1] ?? null;
}
public function getFirst(string $name): ?string
{
return $this->get($name)[0] ?? null;
}
public function has(string $name): bool
{
return isset($this->headers[strtolower($name)]);
}
/**
* Gibt die Header im Format ['Original-Name' => [Wert1, Wert2]]
*/
public function all(): array
{
$output = [];
foreach ($this->headers as [$original, $values]) {
$output[$original] = $values;
}
return $output;
}
private function normalizeName(string $name): string
{
return preg_replace_callback('/(?:^|-)[a-z]/', fn($m) => strtoupper($m[0]), strtolower($name));
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
enum HttpMethod: string
{
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
case PATCH = 'PATCH';
case DELETE = 'DELETE';
case HEAD = 'HEAD';
case OPTIONS = 'OPTIONS';
case TRACE = 'TRACE';
case CONNECT = 'CONNECT';
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final class HttpRequest implements Request
{
public function __construct(
public readonly HttpMethod $method = HttpMethod::GET,
readonly Headers $headers = new Headers(),
readonly string $body = '',
readonly string $path = '',
readonly array $files = [],
readonly Cookies $cookies = new Cookies()
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final readonly class HttpResponse implements Response
{
public function __construct(
public Status $status = Status::OK,
public Headers $headers = new Headers(),
public string $body = ''
/*public private(set) string $body = '' {
get => $this->body;
set => $value;
}*/
) {
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
interface Request
{
public Headers $headers{
get;
}
public string $body{
get;
}
public HttpMethod $method{
get;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
interface Response
{
public string $body{
get;
}
public Status $status{
get;
}
public Headers $headers{
get;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
final class ResponseEmitter
{
public function emit(Response $response): void
{
// Status-Code senden
http_response_code($response->status->value);
// Header senden
/*foreach ($response->headers->all() as $name => $values) {
#foreach ((array)$values as $value) {
header("$name: $values", false);
#}
}*/
// Header senden
foreach ($response->headers->all() as $name => $value) {
// Sicherheitsprüfung
if (!preg_match('/^[A-Za-z0-9\-]+$/', $name)) {
throw new \InvalidArgumentException("Invalid header name: '$name'");
}
// Bei Mehrfach-Headern: ggf. als Array zulassen
if (is_array($value)) {
foreach ($value as $single) {
header("$name: $single", false); // false = nicht ersetzen
}
} else {
header("$name: $value", true);
}
}
// Body ausgeben
echo $response->body;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Framework\Http;
final readonly class ResponseManipulator
{
public function withBody(Response $response, string $body): Response
{
return new HttpResponse(
status: $response->status,
headers: $response->headers,
body: $body
);
}
public function withHeader(Response $response, string $name, string $value): Response
{
$headers = clone $response->headers;
$headers->with($name, $value);
return new HttpResponse(
status: $response->status,
headers: $headers,
body: $response->body
);
}
public function withStatus(Response $response, Status $status): Response
{
return new HttpResponse(
status: $status,
headers: $response->headers,
body: $response->body
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
class Redirect implements Response
{
public private(set) string $body = '';
public private(set) Status $status = Status::FOUND;
public \App\Framework\Http\Headers $headers {
get {
return new Headers()->with('Location', $this->body);
}
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
enum Status: int
{
case OK = 200;
case NOT_FOUND = 404;
case FOUND = 302;
case INTERNAL_SERVER_ERROR = 500;
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\HttpClient;
final readonly class ClientOptions
{
public function __construct(
public float $timeout = 10.0,
public bool $followRedirects = true,
public ?array $auth = null,
public bool $verifySsl = true
// ... beliebig erweiterbar
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Framework\HttpClient;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpMethod;
final readonly class ClientRequest
{
public function __construct(
public HttpMethod $method,
public string $url,
public Headers $headers = new Headers(),
public string $body = '',
public ClientOptions $options = new ClientOptions() // Z.B.. Timeout, Query-Parameter, Auth, etc.
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\HttpClient;
use App\Framework\Http\Headers;
use App\Framework\Http\Status;
final readonly class ClientResponse
{
public function __construct(
public Status $status,
public Headers $headers,
public string $body
) {}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Framework\HttpClient;
use App\Framework\Http\Headers;
use App\Framework\Http\Status;
final readonly class CurlHttpClient implements HttpClient
{
public function send(ClientRequest $request): ClientResponse
{
$ch = curl_init();
$options = [
CURLOPT_URL => $request->url,
CURLOPT_CUSTOMREQUEST => $request->method->value,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
];
if ($request->body !== '') {
$options[CURLOPT_POSTFIELDS] = $request->body;
}
if (count($request->headers->all()) > 0) {
$options[CURLOPT_HTTPHEADER] = $request->headers->all();
}
curl_setopt_array($ch, $options);
$raw = curl_exec($ch);
if ($raw === false) {
throw new HttpException(curl_error($ch), curl_errno($ch));
}
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headersRaw = substr($raw, 0, $headerSize);
$body = substr($raw, $headerSize);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
#$headers = Headers::fromString($headersRaw);
$headers = new Headers();
return new ClientResponse(Status::from($status), $headers, $body);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\HttpClient;
interface HttpClient
{
public function send(ClientRequest $request): ClientResponse;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\Status;
final readonly class ActionResult
{
public function __construct(
public ResultType $resultType,
public string $template,
public array $data = [],
public Status $status = Status::OK,
public string $layout = '',
public array $slots = [],
public ?string $controllerClass = null
) {}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\DynamicRoute;
use App\Framework\Http\HttpMethod;
final readonly class HttpRouter
{
public function __construct(
private RouteCollection $routes
) {}
/**
* Versucht, eine Route zu finden.
*
* @param string $method HTTP-Methode, zB. GET, POST
* @param string $path URI-Pfad, zB. /user/123
*/
public function match(string $method, string $path): RouteContext
{
$method = HttpMethod::tryFrom(strtoupper($method));
if ($method === null || !$this->routes->has($method->value)) {
return new RouteContext(
match: new NoRouteMatch(),
method: $method->value,
path: $path
);
}
$match = $this->matchStatic($method->value, $path)
?? $this->matchDynamic($method->value, $path)
?? new NoRouteMatch();
return new RouteContext(
match: $match,
method: $method->value,
path: $path
);
}
private function matchStatic(string $method, string $path): ?RouteMatch
{
if (isset($this->routes->getStatic($method)[$path])) {
$handler = $this->routes->getStatic($method)[$path];
return new RouteMatchSuccess($handler);
}
return null;
}
private function matchDynamic(string $method, string $path): ?RouteMatch
{
foreach ($this->routes->getDynamic($method) as $route) {
if (preg_match($route['regex'], $path, $matches)) {
array_shift($matches); // remove full match
$params = array_combine($route['params'], $matches);
$dynamicRoute = new DynamicRoute(
$route["regex"],
$route["params"],
$route['method']['class'],
$route['method']['method'],
$params
);
return new RouteMatchSuccess($route);
}
}
return null;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
final readonly class NoRouteMatch implements RouteMatch
{
public function isMatch(): bool
{
return false;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
enum ResultType
{
case Html;
case Json;
case Plain;
// ... weitere Typen wie Redirect, Stream usw. bei Bedarf
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Framework\Router;
final class RouteCollection
{
/** @var array<string, array{static: array<string, callable>, dynamic: array<array{regex: string, handler: callable}>}> */
private array $routes;
public function __construct(array $routes) {
$this->routes = $routes;
}
public function getStatic(string $method): array {
return $this->routes[$method]['static'] ?? [];
}
public function getDynamic(string $method): array {
return $this->routes[$method]['dynamic'] ?? [];
}
public function has(string $method): bool {
return isset($this->routes[$method]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Framework\Router;
use Closure;
final class RouteContext
{
public null|Closure $handler {
get => $this->isSuccess() ? $this->match->route->method : null;
}
public array $params {
get => $this->isSuccess() ? $this->match->route->parameters : [];
}
public function __construct(
public readonly RouteMatch $match,
public readonly string $method,
public readonly string $path
) {}
public function isSuccess(): bool
{
return $this->match instanceof RouteMatchSuccess;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
class RouteDispatcher
{
public function dispatch(RouteContext $routeContext): ActionResult|Response
{
$routeMatch = $routeContext->match;
if ($routeMatch->isMatch()) {
$controller = $routeMatch->route->controller;
$action = $routeMatch->route->action;
$params = $routeMatch->route->params;
$params = $this->prepareParameters(...$params);
$obj = new $controller();
$result = $obj->$action(...$params);
// Hier könntest du z. B. Response-Objekte erwarten oder generieren:
if ($result instanceof Response || $result instanceof ActionResult) {
return $result;
}
}
// Fehlerbehandlung z.B. 404
return new HttpResponse(status: Status::NOT_FOUND, body: 'Nicht gefunden');
}
public function prepareParameters(...$params): mixed
{
$parameters = [];
foreach ($params as $param) {
if ($param['isBuiltin'] === true) {
$parameters[] = $param['default'];
} else {
#Container!
var_dump($param['isBuiltin']);
}
}
return $parameters;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
interface RouteMatch
{
public function isMatch(): bool;
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\DynamicRoute;
use App\Framework\Core\StaticRoute;
final readonly class RouteMatchSuccess implements RouteMatch
{
#public string $controller;
#public string $action;
#public array $params;
public function __construct(
public DynamicRoute|StaticRoute $route
) {
}
public function isMatch(): bool
{
return true;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Response;
use App\Framework\View\Engine;
use App\Framework\View\RenderContext;
use App\Framework\View\TemplateRenderer;
readonly class RouteResponder
{
public function __construct(
private TemplateRenderer $templateRenderer = new Engine()
) {
}
public function respond(ActionResult $result): Response
{
$contentType = "text/html";
switch ($result->resultType) {
case ResultType::Html:
$body = $this->renderTemplate(
$result->template,
$result->data,
$result->layout ?? null,
$result->slots ?? [],
$result->controllerClass
);
$contentType = "text/html";
break;
case ResultType::Json:
$body = json_encode(
$result->data,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
);
$contentType = "application/json";
break;
case ResultType::Plain:
$body = $result->data['text'] ?? '';
$contentType = "text/plain";
break;
default:
throw new \RuntimeException("Unknown result type: {$result->resultType}");
}
return new HttpResponse(
status: $result->status,
headers: new Headers()->with('Content-Type', $contentType), //['Content-Type' => $contentType],
body: $body
);
}
private function renderTemplate(string $template, array $data, ?string $layout, array $slots = [], ?string $controllerName = null): string
{
$context = new RenderContext(
template: $template,
data: $data,
layout: $layout,
slots: $slots,
controllerClass: $controllerName
);
return $this->templateRenderer->render($context);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
final readonly class Compiler
{
public function compile(string $html): \DOMDocument
{
$dom = new \DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
return $dom;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Framework\View;
final class ComponentRenderer
{
public function __construct(
private readonly TemplateLoader $loader = new TemplateLoader(),
private readonly Compiler $compiler = new Compiler(),
private readonly TemplateProcessor $processor = new TemplateProcessor(),
private string $cacheDir = __DIR__ . "/cache/components/"
) {}
public function render(string $componentName, array $data): string
{
$path = $this->loader->getComponentPath($componentName);
if (!file_exists($path)) {
return "<!-- Komponente '$componentName' nicht gefunden -->";
}
# Cache prüfen
$hash = md5_file($path) . '_' . md5(serialize($data));
$cacheFile = $this->cacheDir . "/{$componentName}_{$hash}.html";;
if(file_exists($cacheFile)) {
return file_get_contents($cacheFile);
}
$template = file_get_contents($path);
$compiled = $this->compiler->compile($template)->saveHTML();
$context = new RenderContext(
template: $componentName,
data: $data
);
$output = $this->processor->render($context, $compiled);
if(!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0777, true);
}
file_put_contents($cacheFile, $output);
return $output;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Framework\View\DOM;
class DomParser
{
public function domNodeToHTMLElement(\DOMNode $node, ?HTMLElement $parent = null): ?HTMLElement
{
switch ($node->nodeType) {
case XML_ELEMENT_NODE:
$attributes = [];
if ($node instanceof \DOMElement && $node->hasAttributes()) {
foreach ($node->attributes as $attr) {
$attributes[$attr->nodeName] = $attr->nodeValue;
}
}
$el = new HTMLElement(
tagName: $node->nodeName,
attributes: $attributes,
children: [],
textContent: null,
nodeType: 'element',
namespace: $node->namespaceURI,
parent: $parent
);
foreach ($node->childNodes as $child) {
$childEl = $this->domNodeToHTMLElement($child, $el);
if ($childEl) {
$el->addChild($childEl);
}
}
return $el;
case XML_TEXT_NODE:
return new HTMLElement(textContent: $node->nodeValue, nodeType: 'text');
case XML_COMMENT_NODE:
return new HTMLElement(textContent: $node->nodeValue, nodeType: 'comment');
default:
return null;
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Framework\View\DOM;
class HTMLElement
{
public function __construct(
public string $tagName = '',
public array $attributes = [],
public array $children = [],
public ?string $textContent = null,
public string $nodeType = 'element', // 'element', 'text', 'comment'
public ?string $namespace = null,
public ?HTMLElement $parent = null
) {}
public function attr(string $name, ?string $value = null): self|string|null
{
if ($value === null) {
return $this->attributes[$name] ?? null;
}
$this->attributes[$name] = $value;
return $this;
}
public function text(?string $value = null): self|string|null
{
if ($value === null) {
return $this->textContent;
}
$this->textContent = $value;
return $this;
}
public function addChild(HTMLElement $child): self
{
$child->parent = $this;
$this->children[] = $child;
return $this;
}
public function __toString(): string
{
if ($this->nodeType === 'text') {
return htmlspecialchars($this->textContent ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if ($this->nodeType === 'comment') {
return "<!-- " . $this->textContent . " -->";
}
$attrs = implode(' ', array_map(
fn($k, $v) => htmlspecialchars($k) . '="' . htmlspecialchars($v) . '"',
array_keys($this->attributes),
$this->attributes
));
$content = implode('', array_map(fn($c) => (string)$c, $this->children));
$tag = htmlspecialchars($this->tagName);
return "<{$tag}" . ($attrs ? " $attrs" : "") . ">$content</{$tag}>";
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Framework\View\DOM;
final readonly class HtmlDocument
{
private \DOMDocument $dom;
public function __construct(string $html = '')
{
$this->dom = new \DOMDocument('1.0', 'UTF-8');
if ($html !== '') {
libxml_use_internal_errors(true);
$success = $this->dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
if (!$success) {
throw new \RuntimeException("HTML Parsing failed.");
}
libxml_clear_errors();
}
}
public function querySelector(string $tagName): ?HtmlElement
{
$xpath = new \DOMXPath($this->dom);
$node = $xpath->query("//{$tagName}")->item(0);
return $node ? new DomParser()->domNodeToHTMLElement($node) : null;
}
public function querySelectorAll(string $tagName): NodeList
{
$xpath = new \DOMXPath($this->dom);
$nodes = $xpath->query("//{$tagName}");
$parser = new DomParser();
$elements = [];
foreach ($nodes as $node) {
$el = $parser->domNodeToHTMLElement($node);
if ($el) {
$elements[] = $el;
}
}
return new NodeList(...$elements);
}
public function toHtml(): string
{
return $this->dom->saveHTML() ?: '';
}
public function __toString(): string
{
return $this->toHtml();
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Framework\View\DOM;
class HtmlDocumentFormatter
{
private HtmlFormatter $formatter;
private DomParser $parser;
public function __construct(?HtmlFormatter $formatter = null)
{
$this->formatter = $formatter ?? new HtmlFormatter();
$this->parser = new DomParser();
}
/**
* Wandelt ein HTML-String in ein strukturiertes HTMLElement-Baumobjekt und formatiert es
*/
public function formatHtmlString(string $html): string
{
$document = new \DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$success = $document->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
if (!$success) {
throw new \RuntimeException("HTML Parsing failed.");
}
libxml_clear_errors();
$root = $document->documentElement;
if (!$root) {
return '';
}
$element = $this->parser->domNodeToHTMLElement($root);
return $this->formatter->format($element);
}
/**
* Formatiert ein HtmlDocument direkt
*/
public function formatDocument(HtmlDocument $doc): string
{
$element = $doc->querySelector('html') ?? $doc->querySelector('body');
return $this->formatter->format($element);
}
/**
* Formatiert einen Teilbaum ab einem gegebenen HTMLElement
*/
public function formatElement(HTMLElement $element): string
{
return $this->formatter->format($element);
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Framework\View\DOM;
class HtmlFormatter
{
private int $indentSize;
private string $indentChar;
public function __construct(int $indentSize = 2, string $indentChar = ' ')
{
$this->indentSize = $indentSize;
$this->indentChar = $indentChar;
}
public function format(HTMLElement $element, int $level = 0): string
{
$indent = str_repeat($this->indentChar, $level * $this->indentSize);
if ($element->nodeType === 'text') {
return $indent . htmlspecialchars($element->textContent ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n";
}
if ($element->nodeType === 'comment') {
return $indent . "<!-- " . $element->textContent . " -->\n";
}
$tag = htmlspecialchars($element->tagName);
$attrs = '';
foreach ($element->attributes as $key => $value) {
$attrs .= ' ' . htmlspecialchars($key) . '="' . htmlspecialchars($value) . '"';
}
if (empty($element->children) && empty($element->textContent)) {
return $indent . "<{$tag}{$attrs} />\n";
}
$output = $indent . "<{$tag}{$attrs}>\n";
foreach ($element->children as $child) {
$output .= $this->format($child, $level + 1);
}
if ($element->textContent !== null && $element->textContent !== '') {
$output .= str_repeat($this->indentChar, ($level + 1) * $this->indentSize);
$output .= htmlspecialchars($element->textContent, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n";
}
$output .= $indent . "</{$tag}>\n";
return $output;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Framework\View\DOM;
class NodeList implements \IteratorAggregate, \Countable, \ArrayAccess
{
/** @var HTMLElement[] */
private array $nodes = [];
public function __construct(HTMLElement ...$nodes)
{
$this->nodes = $nodes;
}
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->nodes);
}
public function count(): int
{
return count($this->nodes);
}
public function offsetExists($offset): bool
{
return isset($this->nodes[$offset]);
}
public function offsetGet($offset): mixed
{
return $this->nodes[$offset] ?? null;
}
public function offsetSet($offset, $value): void
{
if (!$value instanceof HTMLElement) {
throw new \InvalidArgumentException("Only HTMLElement instances allowed.");
}
if ($offset === null) {
$this->nodes[] = $value;
} else {
$this->nodes[$offset] = $value;
}
}
public function offsetUnset($offset): void
{
unset($this->nodes[$offset]);
}
// Beispiel für Hilfsmethoden:
public function first(): ?HTMLElement
{
return $this->nodes[0] ?? null;
}
public function filter(callable $fn): self
{
return new self(...array_filter($this->nodes, $fn));
}
public function map(callable $fn): array
{
return array_map($fn, $this->nodes);
}
public function toArray(): array
{
return $this->nodes;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\View;
interface DomProcessor
{
/**
* Manipuliert das gegebene DOM.
* @param \DOMDocument $dom
* @param array $data
* @param callable|null $componentRenderer Optional, kann z.B. für Komponenten übergeben werden
*/
##public function process(\DOMDocument $dom, array $data, ?callable $componentRenderer = null): void;
public function process(\DOMDocument $dom, RenderContext $context): void;
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Framework\View;
use DOMDocument;
final class DomTemplateParser
{
public function parse(string $html): DOMDocument
{
libxml_use_internal_errors(true);
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
$dom->loadHTML(
$html,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
return $dom;
}
public function toHtml(DOMDocument $dom): string
{
return $dom->saveHTML();
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\View\Processors\ComponentProcessor;
use App\Framework\View\Processors\LayoutTagProcessor;
use App\Framework\View\Processors\PlaceholderReplacer;
final readonly class Engine implements TemplateRenderer
{
public function __construct(
private TemplateLoader $loader = new TemplateLoader(),
private Compiler $compiler = new Compiler(),
private Renderer $renderer = new Renderer(),
private TemplateProcessor $processor = new TemplateProcessor()
) {
$this->processor->registerDom(new ComponentProcessor());
$this->processor->registerDom(new LayoutTagProcessor());
$this->processor->registerDom(new PlaceholderReplacer());
}
public function render(RenderContext $context): string
{
$template = $context->template;
$data = $context->data;
$cacheFile = __DIR__ . "/cache/{$template}.cache.html";
$templateFile = $this->loader->getTemplatePath($template); // Neue Methode in TemplateLoader
// Prüfen ob Cache existiert und nicht älter als das Template
if (file_exists($cacheFile) && filemtime($cacheFile) >= filemtime($templateFile)) {
$content = file_get_contents($cacheFile);
#$dom = $this->compiler->compile($content);
} else {
// Template normal laden und kompilieren
$content = $this->loader->load($template, $context->controllerClass);;
$content = "<test>{$content}</test>";
$dom = $this->compiler->compile($content);
$html = $dom->saveHTML();
// (Optional) VOR dynamischer Verarbeitung rohe Struktur cachen
file_put_contents($cacheFile, $dom->saveHTML());
$content = $html;
}
return $this->processor->render($context, $content);
#return $this->renderer->render($dom, $data, $this);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
use App\Framework\View\RenderContext;
final readonly class CommentStripProcessor implements DomProcessor
{
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//comment()') as $commentNode) {
$commentNode->parentNode?->removeChild($commentNode);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\ComponentRenderer;
use App\Framework\View\DomProcessor;
use App\Framework\View\RenderContext;
final readonly class ComponentProcessor implements DomProcessor
{
public function __construct(
private ComponentRenderer $renderer = new ComponentRenderer()
){}
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//component') as $node) {
$name = $node->getAttribute('name');
$attributes = [];
foreach ($node->attributes as $attr) {
if($attr->nodeName !== 'name') {
$attributes[$attr->nodeName] = $attr->nodeValue;
}
}
$componentHtml = $this->renderer->render($name, array_merge($context->data, $attributes));
$fragment = $dom->createDocumentFragment();
$fragment->appendXML($componentHtml);
$node->parentNode->replaceChild($fragment, $node);
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\RenderContext;
use App\Framework\View\StringProcessor;
# Formatiert Datumsfelder etwa mit einem Tag `<date value="iso_date" format="d.m.Y" />`.
final readonly class DateFormatProcessor implements StringProcessor
{
/**
* @inheritDoc
*/
public function process(string $html, RenderContext $context): string
{
return preg_replace_callback('/{{\s*date\((\w+),\s*["\']([^"\']+)["\']\)\s*}}/', function ($matches) use ($context) {
$key = $matches[1];
$format = $matches[2];
if (!isset($context->data[$key]) || !($context->data[$key] instanceof \DateTimeInterface)) {
return $matches[0]; // Unverändert lassen
}
return $context->data[$key]->format($format);
}, $html);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\RenderContext;
final readonly class EscapeProcessor
{
public function process(string $html, RenderContext $context): string
{
// Erst rohe Inhalte einsetzen (drei geschweifte Klammern)
$html = preg_replace_callback('/{{{\s*(\w+)\s*}}}/', function ($matches) use ($context) {
return $context->data[$matches[1]] ?? '';
}, $html);
// Dann alle übrigen Variablen escapen
$html = preg_replace_callback('/{{\s*(\w+)\s*}}/', function ($matches) use ($context) {
$value = $context->data[$matches[1]] ?? '';
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5);
}, $html);
return $html;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
final class ForProcessor implements DomProcessor
{
/**
* @inheritDoc
*/
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//for[@var][@in]') as $node) {
$var = $node->getAttribute('var');
$in = $node->getAttribute('in');
$output = '';
if (isset($context->data[$in]) && is_iterable($context->data[$in])) {
foreach ($context->data[$in] as $item) {
$clone = $node->cloneNode(true);
foreach ($clone->childNodes as $child) {
$this->replacePlaceholdersRecursive($child, [$var => $item] + $context->data);
}
$fragment = $dom->createDocumentFragment();
foreach ($clone->childNodes as $child) {
$fragment->appendChild($child->cloneNode(true));
}
$output .= $dom->saveHTML($fragment);
}
}
$replacement = $dom->createDocumentFragment();
@$replacement->appendXML($output);
$node->parentNode?->replaceChild($replacement, $node);
}
}
private function replacePlaceholdersRecursive(\DOMNode $node, array $data): void
{
if ($node->nodeType === XML_TEXT_NODE) {
$node->nodeValue = preg_replace_callback('/{{\s*(\w+)\s*}}/', function ($matches) use ($data) {
return htmlspecialchars((string)($data[$matches[1]] ?? $matches[0]), ENT_QUOTES | ENT_HTML5);
}, $node->nodeValue);
}
if ($node->hasChildNodes()) {
foreach ($node->childNodes as $child) {
$this->replacePlaceholdersRecursive($child, $data);
}
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
final readonly class IfProcessor implements DomProcessor
{
public function process(\DOMDocument $dom, \App\Framework\View\RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//*[@if]') as $node) {
$condition = $node->getAttribute('if');
$value = $context->data[$condition] ?? null;
// Entferne, wenn die Bedingung nicht erfüllt ist
if (!$this->isTruthy($value)) {
$node->parentNode?->removeChild($node);
continue;
}
// Entferne Attribut bei Erfolg
$node->removeAttribute('if');
}
}
private function isTruthy(mixed $value): bool
{
if (is_bool($value)) return $value;
if (is_null($value)) return false;
if (is_string($value)) return trim($value) !== '';
if (is_numeric($value)) return $value != 0;
if (is_array($value)) return count($value) > 0;
return true;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
use App\Framework\View\DomTemplateParser;
use App\Framework\View\RenderContext;
use App\Framework\View\TemplateLoader;
final readonly class IncludeProcessor implements DomProcessor
{
public function __construct(
private TemplateLoader $loader,
private DomTemplateParser $parser = new DomTemplateParser()
) {}
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//include[@file]') as $includeNode) {
$file = $includeNode->getAttribute('file');
try {
$html = $this->loader->load($file);
$includedDom = $this->parser->parse($html);
$fragment = $dom->createDocumentFragment();
foreach ($includedDom->documentElement->childNodes as $child) {
$fragment->appendChild($dom->importNode($child, true));
}
$includeNode->parentNode?->replaceChild($fragment, $includeNode);
} catch (\Throwable $e) {
// Optional: Fehlerkommentar ins Template schreiben
$error = $dom->createComment("Fehler beim Laden von '$file': " . $e->getMessage());
$includeNode->parentNode?->replaceChild($error, $includeNode);
}
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomTemplateParser;
use App\Framework\View\RenderContext;
use App\Framework\View\TemplateLoader;
use App\Framework\View\DomProcessor;
final readonly class LayoutTagProcessor implements DomProcessor
{
public function __construct(
private TemplateLoader $loader = new TemplateLoader(),
private DomTemplateParser $parser = new DomTemplateParser()
) {}
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
$layoutTags = $xpath->query('//layout[@src]');
if ($layoutTags->length === 0) {
return;
}
$layoutTag = $layoutTags->item(0);
$layoutFile = $layoutTag->getAttribute('src');
$layoutHtml = $this->loader->load('/layouts/'.$layoutFile);
$layoutDom = $this->parser->parse($layoutHtml);
// Body-Slot finden
$layoutXPath = new \DOMXPath($layoutDom);
/*$slotNodes = $layoutXPath->query('//slot[@name="body"]');
if ($slotNodes->length === 0) {
return;
}
$slot = $slotNodes->item(0);*/
$slot = $layoutXPath->query('//main')->item(0);
if (! $slot) {
return; // Kein <main> vorhanden → Layout kann nicht angewendet werden
}
// Die Kindknoten des ursprünglichen <layout>-Elternelements einsammeln (ohne <layout>-Tag selbst)
$parent = $layoutTag->parentNode;
// Alle Knoten nach <layout> einsammeln (direkt nach <layout>)
$contentNodes = [];
for ($node = $layoutTag->nextSibling; $node !== null; $node = $node->nextSibling) {
$contentNodes[] = $node;
}
// Vor dem Entfernen: Layout-Tag aus DOM löschen
$parent->removeChild($layoutTag);
// Inhalt einfügen
$fragment = $layoutDom->createDocumentFragment();
foreach ($contentNodes as $contentNode) {
// Hole alle noch im Original existierenden Nodes...
$fragment->appendChild($layoutDom->importNode($contentNode, true));
}
// <main> ersetzen
$slot->parentNode->replaceChild($fragment, $slot);
// Ersetze gesamtes DOM
$newDom = $this->parser->parse($layoutDom->saveHTML());
$dom->replaceChild(
$dom->importNode($newDom->documentElement, true),
$dom->documentElement
);
return;
// Layout-Tag aus Original entfernen
$layoutTag->parentNode?->removeChild($layoutTag);
// Inhalt des Haupttemplates extrahieren (ohne das Layout-Tag selbst)
$fragment = $dom->createDocumentFragment();
foreach ($dom->documentElement->childNodes as $child) {
$fragment->appendChild($layoutDom->importNode($child, true));
}
// Ersetze Slot im Layout durch den gerenderten Body
$slot->parentNode?->replaceChild($fragment, $slot);
// Ersetze gesamtes DOM durch Layout-DOM
$newDom = $this->parser->parse($layoutDom->saveHTML());
$dom->replaceChild(
$dom->importNode($newDom->documentElement, true),
$dom->documentElement
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
use App\Framework\View\RenderContext;
final readonly class MetaManipulator implements DomProcessor
{
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//meta[@name][@content]') as $meta) {
$name = $meta->getAttribute('name');
$content = $meta->getAttribute('content');
// Wenn Variable bereits im Context gesetzt ist, nicht überschreiben
if (!array_key_exists($name, $context->data)) {
$context->data[$name] = $content;
}
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
use App\Framework\View\RenderContext;
final readonly class PlaceholderReplacer implements DomProcessor
{
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//text()') as $textNode) {
$textNode->nodeValue = preg_replace_callback(
'/{{\s*([\w.]+)\s*}}/',
fn($m) => $this->resolveValue($context->data, $m[1]),
$textNode->nodeValue
);
}
}
private function resolveValue(array $data, string $expr): string
{
$keys = explode('.', $expr);
$value = $data;
foreach ($keys as $key) {
if (!is_array($value) || !array_key_exists($key, $value)) {
return "{{ $expr }}"; // Platzhalter bleibt erhalten
}
$value = $value[$key];
}
return is_scalar($value) ? (string)$value : '';
}
public function supports(\DOMElement $element): bool
{
return $element->tagName === 'text';
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
use App\Framework\View\RenderContext;
/*
<component name="card">
<slot name="header">Default Header</slot>
<slot>Main Content</slot>
</component>
*/
final readonly class SlotProcessor implements DomProcessor
{
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//slot[@name]') as $slotNode) {
$slotName = $slotNode->getAttribute('name');
$html = $context->slots[$slotName] ?? null;
$replacement = $dom->createDocumentFragment();
if ($html !== null) {
@$replacement->appendXML($html);
} else {
// Fallback-Inhalt erhalten (die inneren Nodes des slot-Tags)
foreach ($slotNode->childNodes as $child) {
$replacement->appendChild($child->cloneNode(true));
}
}
$slotNode->parentNode?->replaceChild($replacement, $slotNode);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Framework\View\Processors;
use App\Framework\View\DomProcessor;
use App\Framework\View\RenderContext;
use DOMXPath;
# Ersetzt ein eigenes `<switch value="foo">` mit inneren `<case when="bar">`-Elementen.
final readonly class SwitchCaseProcessor implements DomProcessor
{
/**
* @inheritDoc
*/
public function process(\DOMDocument $dom, RenderContext $context): void
{
$xpath = new DOMXPath($dom);
foreach ($xpath->query('//switch[@value]') as $switchNode) {
$key = $switchNode->getAttribute('value');
$value = $context->data[$key] ?? null;
$matchingCase = null;
$defaultCase = null;
foreach ($switchNode->childNodes as $child) {
if (!($child instanceof \DOMElement)) {
continue;
}
if ($child->tagName === 'case') {
$caseValue = $child->getAttribute('value');
if ((string)$value === $caseValue) {
$matchingCase = $child;
break;
}
}
if ($child->tagName === 'default') {
$defaultCase = $child;
}
}
$replacement = $dom->createDocumentFragment();
if ($matchingCase) {
foreach ($matchingCase->childNodes as $child) {
$replacement->appendChild($child->cloneNode(true));
}
} elseif ($defaultCase) {
foreach ($defaultCase->childNodes as $child) {
$replacement->appendChild($child->cloneNode(true));
}
}
$switchNode->parentNode?->replaceChild($replacement, $switchNode);
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Framework\View;
final readonly class RenderContext
{
public function __construct(
public string $template, // Dateiname oder Template-Key
public array $data = [], // Variablen wie ['title' => '...']
public ?string $layout = null, // Optionales Layout
public array $slots = [], // Benannte Slots wie ['main' => '<p>...</p>']
public ?string $controllerClass = null
) {}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\View\Processors\CommentStripProcessor;
use App\Framework\View\Processors\ComponentProcessor;
use App\Framework\View\Processors\LayoutTagProcessor;
use App\Framework\View\Processors\MetaManipulator;
use App\Framework\View\Processors\PlaceholderReplacer;
final readonly class Renderer
{
public function __construct(
private TemplateManipulator $manipulator = new TemplateManipulator(
new LayoutTagProcessor(),
#new PlaceholderReplacer(),
new MetaManipulator(),
new ComponentProcessor(),
new CommentStripProcessor()
)
){}
public function render(\DOMDocument $dom, array $data, Engine $engine): string
{
$componentRenderer = function (string $name, array $attributes, array $data) use ($engine) {
$filename = __DIR__ . "/templates/components/$name.html";
if (!file_exists($filename)) {
return "<!-- Komponente '$name' nicht gefunden -->";
}
$content = file_get_contents($filename);
// Optional: Rekursive/statische Komponentenverarbeitung
return $this->renderComponentPartial($content, array_merge($data, $attributes), $engine);
};
$this->manipulator->manipulate($dom, $data, $componentRenderer);
$xpath = new \DOMXPath($dom);
// 2. Schleifen <for var="item" in="items"></for>
foreach ($xpath->query('//*[local-name() = "for"]') as $forNode) {
$var = $forNode->getAttribute('var');
$in = $forNode->getAttribute('in');
$html = '';
if (isset($data[$in]) && is_iterable($data[$in])) {
foreach ($data[$in] as $item) {
$clone = $forNode->cloneNode(true);
foreach ($clone->childNodes as $node) {
$this->renderNode($node, [$var => $item] + $data);
}
$frag = $dom->createDocumentFragment();
foreach ($clone->childNodes as $child) {
$frag->appendChild($child->cloneNode(true));
}
$html .= $dom->saveHTML($frag);
}
}
// Ersetze das <for>-Element
$fragment = $dom->createDocumentFragment();
$fragment->appendXML($html);
$forNode->parentNode->replaceChild($fragment, $forNode);
}
// Analog: <if>- und <include>-Elemente einbauen (optional, auf Anfrage)
return $dom->saveHTML();
}
private function renderNode(\DOMNode $node, array $data): void
{
// Rekursiv auf allen Kindknoten Platzhalter ersetzen (wie oben)
if ($node->nodeType === XML_TEXT_NODE) {
$original = $node->nodeValue;
$replaced = preg_replace_callback('/{{\s*(\w+(?:\.\w+)*)\s*}}/', function ($matches) use ($data) {
$keys = explode('.', $matches[1]);
$value = $data;
foreach ($keys as $key) {
$value = is_array($value) && array_key_exists($key, $value) ? $value[$key] : $matches[0];
}
return is_scalar($value)
? htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5)
: $matches[0];
}, $original);
if ($original !== $replaced) {
$node->nodeValue = $replaced;
}
}
if ($node->hasChildNodes()) {
foreach ($node->childNodes as $child) {
$this->renderNode($child, $data);
}
}
}
/**
* Rendert ein Komponententemplate inklusive rekursivem Komponenten-Parsing.
*/
private function renderComponentPartial(string $content, array $data, Engine $engine): string
{
$cacheDir = __DIR__ . "/cache/components";
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0777, true);
}
$hash = md5($content . serialize($data));
$cacheFile = $cacheDir . "/component_$hash.html";
if (file_exists($cacheFile)) {
return file_get_contents($cacheFile);
}
$dom = new \DOMDocument();
@$dom->loadHTML('<!DOCTYPE html><html lang="de"><body>'.$content.'</body></html>');
// Komponenten innerhalb des Partials auflösen (rekursiv!)
$this->manipulator->processComponents(
$dom,
$data,
function ($name, $attributes, $data) use ($engine) {
$filename = __DIR__ . "/templates/components/$name.html";
if (!file_exists($filename)) {
return "<!-- Komponente '$name' nicht gefunden -->";
}
$subContent = file_get_contents($filename);
return $this->renderComponentPartial($subContent, array_merge($data, $attributes), $engine);
}
);
// Platzhalter in diesem Partial ersetzen
$this->manipulator->replacePlaceholders($dom, $data);
// Nur den Inhalt von <body> extrahieren
$body = $dom->getElementsByTagName('body')->item(0);
$innerHTML = '';
foreach ($body->childNodes as $child) {
$innerHTML .= $dom->saveHTML($child);
}
file_put_contents($cacheFile, $innerHTML);
return $innerHTML;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\View;
interface StringProcessor
{
public function process(string $html, RenderContext $context): string;
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
final readonly class TemplateLoader
{
public function __construct(
private string $templatePath = __DIR__ . '/templates'
) {
}
public function load(string $template, ?string $controllerClass = null): string
{
$file = $this->getTemplatePath($template, $controllerClass);
if (! file_exists($file)) {
throw new \RuntimeException("Template \"$template\" nicht gefunden ($file).");
}
return file_get_contents($file);
}
public function getTemplatePath(string $template, ?string $controllerClass = null): string
{
if($controllerClass) {
$rc = new \ReflectionClass($controllerClass);
$dir = dirname($rc->getFileName());
return $dir . DIRECTORY_SEPARATOR . $template . '.html';
}
return $this->templatePath . '/' . $template . '.html';
}
public function getComponentPath(string $name): string
{
return __DIR__ . "/templates/components/{$name}.html";
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Framework\View;
final readonly class TemplateManipulator
{
private array $processors;
public function __construct(
DomProcessor ...$processor
){
$this->processors = $processor;
}
public function manipulate(\DOMDocument $dom, array $data, callable $componentRenderer):void
{
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//*') as $element) {
if (!$element instanceof \DOMElement) continue;
foreach ($this->processors as $processor) {
if ($processor->supports($element)) {
$processor->process($dom, $data, $componentRenderer);
}
}
}
}
// Du könntest hier noch Platzhalter, Komponenten etc. ergänzen
public function replacePlaceholders(\DOMDocument $dom, array $data): void
{
// Einfaches Beispiel für {{ variable }}-Platzhalter in Textknoten
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//text()') as $textNode) {
foreach ($data as $key => $value) {
$placeholder = '{{ ' . $key . ' }}';
if (str_contains($textNode->nodeValue, $placeholder)) {
$textNode->nodeValue = str_replace($placeholder, $value, $textNode->nodeValue);
}
}
}
}
/**
* Parst und ersetzt <component>-Elemente im DOM.
* @param \DOMDocument $dom
* @param array $data
* @param callable $componentRenderer Funktion: (Name, Attribute, Daten) => HTML
*/
public function processComponents(\DOMDocument $dom, array $data, callable $componentRenderer): void
{
$xpath = new \DOMXPath($dom);
// Alle <component>-Elemente durchgehen (XPath ist hier namespace-unabhängig)
foreach ($xpath->query('//component') as $componentNode) {
/** @var \DOMElement $componentNode */
$name = $componentNode->getAttribute('name');
// Alle Attribute als Array sammeln
$attributes = [];
foreach ($componentNode->attributes as $attr) {
$attributes[$attr->nodeName] = $attr->nodeValue;
}
// Hole das gerenderte HTML für diese Komponente
$componentHtml = $componentRenderer($name, $attributes, $data);
// Ersetze das Node durch neues HTML-Fragment
$fragment = $dom->createDocumentFragment();
$fragment->appendXML($componentHtml);
$componentNode->parentNode->replaceChild($fragment, $componentNode);
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Framework\View;
class TemplateProcessor
{
/** @var DomProcessor[] */
private array $domProcessors = [];
/** @var StringProcessor[] */
private array $stringProcessors = [];
public function registerDom(DomProcessor $processor): void
{
$this->domProcessors[] = $processor;
}
public function registerString(StringProcessor $processor): void
{
$this->stringProcessors[] = $processor;
}
public function render(RenderContext $context, string $html): string
{
$parser = new DomTemplateParser();
$dom = $parser->parse($html);
foreach ($this->domProcessors as $processor) {
$processor->process($dom, $context);
}
$html = $parser->toHtml($dom);
foreach ($this->stringProcessors as $processor) {
$html = $processor->process($html, $context);
}
return $html;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\View;
interface TemplateRenderer
{
public function render(RenderContext $context): string;
}

View File

@@ -0,0 +1,5 @@
<test><layout src="main"></layout>
<h1>EPK</h1>
<p>Das ist mein EPK</p>
</test>

View File

@@ -0,0 +1,4 @@
<test><layout src="main"></layout>
<h1>Impressum!</h1>
<p>Das ist deine Seite</p>
</test>

View File

@@ -0,0 +1,4 @@
<test><layout src="main"></layout>
<h1>Willkommen!</h1>
<p>Das ist deine Seite</p>
</test>

View File

@@ -0,0 +1,3 @@
<div class="alert alert-{{ type }}">
{{ message }}
</div>

View File

@@ -0,0 +1,6 @@
<div class="card">
<div class="card-title">{{ title }}</div>
<div class="card-body">
<component name="alert" type="warning" message="{{ message }}" />
</div>
</div>

View File

@@ -0,0 +1,4 @@
<layout src="main"></layout>
<h1>EPK</h1>
<p>Das ist mein EPK</p>

View File

@@ -0,0 +1,3 @@
<layout src="main"/>
<h1>Impressum!</h1>
<p>Das ist deine Seite</p>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<!-- Favicon (optional) -->
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<!-- CSS Reset oder Normalize (optional) -->
<!-- <link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css"> -->
<!-- Eigene Styles -->
<link rel="stylesheet" href="http://localhost:5173/assets/css/styles.css">
<!-- Open Graph / SEO Meta-Tags (optional) -->
<meta name="description" content="Kurzbeschreibung der Seite">
<meta property="og:title" content="Titel der Seite">
<meta property="og:description" content="Kurzbeschreibung der Seite">
<meta property="og:type" content="website">
<meta property="og:url" content="https://example.com">
<meta property="og:image" content="https://example.com/image.jpg">
<!-- Dark Mode Unterstützung (optional) -->
<meta name="color-scheme" content="light dark">
</head>
<body>
<header>
<div>
<a href="/" aria-label="Zur Startseite">
Logo
</a>
<button aria-label="Navigation öffnen" aria-controls="Main Navigation" aria-expanded="false"></button>
</div>
<nav aria-label="Main Navigation">
<!-- Navigation -->
</nav>
</header>
<main>
<h1>Willkommen!</h1>
<p>Das ist der Hauptinhalt deiner Seite.</p>
<component name="alert" type="danger" message="Dies ist ein Hinweis" />
<component name="card" title="Titel" message="Nachricht" />
<!-- @dev START -->
<div>Dies ist nur für Entwickler sichtbar.</div>
<!-- @dev END -->
<div>Das ist öffentlich.</div>
</main>
<footer role="contentinfo">
<p>&copy; 2025 Michael Schiemer</p>
<nav aria-label="Footer Navigation">
<ul>
<li><a href="/impressum">Impressum</a></li>
<li><a href="/impressum">Datenschutz</a></li>
</ul>
</nav>
</footer>
<!-- Scripts -->
<script src="/assets/js/main.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
<layout src="main"/>
<h1>Willkommen!</h1>
<p>Das ist deine Seite</p>