chore: lots of changes
This commit is contained in:
21
src/Application/Api/IrkEndpoint.php
Normal file
21
src/Application/Api/IrkEndpoint.php
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/Application/EPK/ShowEpk.php
Normal file
16
src/Application/EPK/ShowEpk.php
Normal 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!']);
|
||||
}
|
||||
}
|
||||
33
src/Application/Website/ShowHome.php
Normal file
33
src/Application/Website/ShowHome.php
Normal 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!'],
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/Application/Website/ShowImpressum.php
Normal file
32
src/Application/Website/ShowImpressum.php
Normal 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!'],
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/Framework/Attributes/Route.php
Normal file
18
src/Framework/Attributes/Route.php
Normal 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',
|
||||
) {
|
||||
}
|
||||
}
|
||||
9
src/Framework/Attributes/Singleton.php
Normal file
9
src/Framework/Attributes/Singleton.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Attributes;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
final class Singleton
|
||||
{
|
||||
|
||||
}
|
||||
17
src/Framework/Core/AttributeMapper.php
Normal file
17
src/Framework/Core/AttributeMapper.php
Normal 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;
|
||||
}
|
||||
171
src/Framework/Core/Discovery.php
Normal file
171
src/Framework/Core/Discovery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/Framework/Core/DynamicRoute.php
Normal file
17
src/Framework/Core/DynamicRoute.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
43
src/Framework/Core/PhpObjectExporter.php
Normal file
43
src/Framework/Core/PhpObjectExporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/Framework/Core/Route.php
Normal file
9
src/Framework/Core/Route.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Core;
|
||||
|
||||
interface Route
|
||||
{
|
||||
}
|
||||
48
src/Framework/Core/RouteCache.php
Normal file
48
src/Framework/Core/RouteCache.php
Normal 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;
|
||||
}
|
||||
}
|
||||
64
src/Framework/Core/RouteCompiler.php
Normal file
64
src/Framework/Core/RouteCompiler.php
Normal 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 . '$~';
|
||||
}
|
||||
}
|
||||
30
src/Framework/Core/RouteMapper.php
Normal file
30
src/Framework/Core/RouteMapper.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
15
src/Framework/Core/StaticRoute.php
Normal file
15
src/Framework/Core/StaticRoute.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
47
src/Framework/DI/Container.php
Normal file
47
src/Framework/DI/Container.php
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/Framework/ErrorHandling/ErrorHandler.php
Normal file
63
src/Framework/ErrorHandling/ErrorHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
src/Framework/Http/Cookie.php
Normal file
51
src/Framework/Http/Cookie.php
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/Framework/Http/Cookies.php
Normal file
15
src/Framework/Http/Cookies.php
Normal 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 = [])
|
||||
{
|
||||
}
|
||||
}
|
||||
90
src/Framework/Http/Headers.php
Normal file
90
src/Framework/Http/Headers.php
Normal 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));
|
||||
}
|
||||
}
|
||||
19
src/Framework/Http/HttpMethod.php
Normal file
19
src/Framework/Http/HttpMethod.php
Normal 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';
|
||||
|
||||
}
|
||||
18
src/Framework/Http/HttpRequest.php
Normal file
18
src/Framework/Http/HttpRequest.php
Normal 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()
|
||||
) {
|
||||
}
|
||||
}
|
||||
20
src/Framework/Http/HttpResponse.php
Normal file
20
src/Framework/Http/HttpResponse.php
Normal 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;
|
||||
}*/
|
||||
) {
|
||||
}
|
||||
}
|
||||
22
src/Framework/Http/Request.php
Normal file
22
src/Framework/Http/Request.php
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
20
src/Framework/Http/Response.php
Normal file
20
src/Framework/Http/Response.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/Framework/Http/ResponseEmitter.php
Normal file
41
src/Framework/Http/ResponseEmitter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/Framework/Http/ResponseManipulator.php
Normal file
35
src/Framework/Http/ResponseManipulator.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/Framework/Http/Responses/Redirect.php
Normal file
18
src/Framework/Http/Responses/Redirect.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Framework/Http/Status.php
Normal file
13
src/Framework/Http/Status.php
Normal 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;
|
||||
}
|
||||
15
src/Framework/HttpClient/ClientOptions.php
Normal file
15
src/Framework/HttpClient/ClientOptions.php
Normal 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
|
||||
) {}
|
||||
|
||||
}
|
||||
18
src/Framework/HttpClient/ClientRequest.php
Normal file
18
src/Framework/HttpClient/ClientRequest.php
Normal 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.
|
||||
) {}
|
||||
|
||||
}
|
||||
16
src/Framework/HttpClient/ClientResponse.php
Normal file
16
src/Framework/HttpClient/ClientResponse.php
Normal 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
|
||||
) {}
|
||||
|
||||
}
|
||||
49
src/Framework/HttpClient/CurlHttpClient.php
Normal file
49
src/Framework/HttpClient/CurlHttpClient.php
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
8
src/Framework/HttpClient/HttpClient.php
Normal file
8
src/Framework/HttpClient/HttpClient.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\HttpClient;
|
||||
|
||||
interface HttpClient
|
||||
{
|
||||
public function send(ClientRequest $request): ClientResponse;
|
||||
}
|
||||
20
src/Framework/Router/ActionResult.php
Normal file
20
src/Framework/Router/ActionResult.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
75
src/Framework/Router/HttpRouter.php
Normal file
75
src/Framework/Router/HttpRouter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/Framework/Router/NoRouteMatch.php
Normal file
13
src/Framework/Router/NoRouteMatch.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/Framework/Router/ResultType.php
Normal file
13
src/Framework/Router/ResultType.php
Normal 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
|
||||
}
|
||||
25
src/Framework/Router/RouteCollection.php
Normal file
25
src/Framework/Router/RouteCollection.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
27
src/Framework/Router/RouteContext.php
Normal file
27
src/Framework/Router/RouteContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/Framework/Router/RouteDispatcher.php
Normal file
51
src/Framework/Router/RouteDispatcher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/Framework/Router/RouteMatch.php
Normal file
10
src/Framework/Router/RouteMatch.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Router;
|
||||
|
||||
interface RouteMatch
|
||||
{
|
||||
public function isMatch(): bool;
|
||||
}
|
||||
24
src/Framework/Router/RouteMatchSuccess.php
Normal file
24
src/Framework/Router/RouteMatchSuccess.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/Framework/Router/RouteResponder.php
Normal file
73
src/Framework/Router/RouteResponder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/Framework/View/Compiler.php
Normal file
18
src/Framework/View/Compiler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/Framework/View/ComponentRenderer.php
Normal file
47
src/Framework/View/ComponentRenderer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/Framework/View/DOM/DomParser.php
Normal file
48
src/Framework/View/DOM/DomParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
65
src/Framework/View/DOM/HTMLElement.php
Normal file
65
src/Framework/View/DOM/HTMLElement.php
Normal 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}>";
|
||||
}
|
||||
}
|
||||
57
src/Framework/View/DOM/HtmlDocument.php
Normal file
57
src/Framework/View/DOM/HtmlDocument.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
55
src/Framework/View/DOM/HtmlDocumentFormatter.php
Normal file
55
src/Framework/View/DOM/HtmlDocumentFormatter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/Framework/View/DOM/HtmlFormatter.php
Normal file
53
src/Framework/View/DOM/HtmlFormatter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/Framework/View/DOM/NodeList.php
Normal file
73
src/Framework/View/DOM/NodeList.php
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/Framework/View/DomProcessor.php
Normal file
16
src/Framework/View/DomProcessor.php
Normal 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;
|
||||
}
|
||||
31
src/Framework/View/DomTemplateParser.php
Normal file
31
src/Framework/View/DomTemplateParser.php
Normal 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();
|
||||
}
|
||||
}
|
||||
53
src/Framework/View/Engine.php
Normal file
53
src/Framework/View/Engine.php
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/Framework/View/Processors/CommentStripProcessor.php
Normal file
18
src/Framework/View/Processors/CommentStripProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Framework/View/Processors/ComponentProcessor.php
Normal file
37
src/Framework/View/Processors/ComponentProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/Framework/View/Processors/DateFormatProcessor.php
Normal file
29
src/Framework/View/Processors/DateFormatProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/Framework/View/Processors/EscapeProcessor.php
Normal file
24
src/Framework/View/Processors/EscapeProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/Framework/View/Processors/ForProcessor.php
Normal file
59
src/Framework/View/Processors/ForProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/Framework/View/Processors/IfProcessor.php
Normal file
39
src/Framework/View/Processors/IfProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/Framework/View/Processors/IncludeProcessor.php
Normal file
41
src/Framework/View/Processors/IncludeProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/Framework/View/Processors/LayoutTagProcessor.php
Normal file
102
src/Framework/View/Processors/LayoutTagProcessor.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/Framework/View/Processors/MetaManipulator.php
Normal file
24
src/Framework/View/Processors/MetaManipulator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/Framework/View/Processors/PlaceholderReplacer.php
Normal file
42
src/Framework/View/Processors/PlaceholderReplacer.php
Normal 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';
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
42
src/Framework/View/Processors/SlotProcessor.php
Normal file
42
src/Framework/View/Processors/SlotProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/Framework/View/Processors/SwitchCaseProcessor.php
Normal file
61
src/Framework/View/Processors/SwitchCaseProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/Framework/View/RenderContext.php
Normal file
14
src/Framework/View/RenderContext.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
145
src/Framework/View/Renderer.php
Normal file
145
src/Framework/View/Renderer.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
8
src/Framework/View/StringProcessor.php
Normal file
8
src/Framework/View/StringProcessor.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\View;
|
||||
|
||||
interface StringProcessor
|
||||
{
|
||||
public function process(string $html, RenderContext $context): string;
|
||||
}
|
||||
38
src/Framework/View/TemplateLoader.php
Normal file
38
src/Framework/View/TemplateLoader.php
Normal 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";
|
||||
}
|
||||
}
|
||||
73
src/Framework/View/TemplateManipulator.php
Normal file
73
src/Framework/View/TemplateManipulator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
40
src/Framework/View/TemplateProcessor.php
Normal file
40
src/Framework/View/TemplateProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/Framework/View/TemplateRenderer.php
Normal file
10
src/Framework/View/TemplateRenderer.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View;
|
||||
|
||||
interface TemplateRenderer
|
||||
{
|
||||
public function render(RenderContext $context): string;
|
||||
}
|
||||
5
src/Framework/View/cache/epk.cache.html
vendored
Normal file
5
src/Framework/View/cache/epk.cache.html
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<test><layout src="main"></layout>
|
||||
|
||||
<h1>EPK</h1>
|
||||
<p>Das ist mein EPK</p>
|
||||
</test>
|
||||
4
src/Framework/View/cache/impressum.cache.html
vendored
Normal file
4
src/Framework/View/cache/impressum.cache.html
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<test><layout src="main"></layout>
|
||||
<h1>Impressum!</h1>
|
||||
<p>Das ist deine Seite</p>
|
||||
</test>
|
||||
4
src/Framework/View/cache/test.cache.html
vendored
Normal file
4
src/Framework/View/cache/test.cache.html
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<test><layout src="main"></layout>
|
||||
<h1>Willkommen!</h1>
|
||||
<p>Das ist deine Seite</p>
|
||||
</test>
|
||||
3
src/Framework/View/templates/components/alert.html
Normal file
3
src/Framework/View/templates/components/alert.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="alert alert-{{ type }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
6
src/Framework/View/templates/components/card.html
Normal file
6
src/Framework/View/templates/components/card.html
Normal 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>
|
||||
4
src/Framework/View/templates/epk.html
Normal file
4
src/Framework/View/templates/epk.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<layout src="main"></layout>
|
||||
|
||||
<h1>EPK</h1>
|
||||
<p>Das ist mein EPK</p>
|
||||
3
src/Framework/View/templates/impressum.html
Normal file
3
src/Framework/View/templates/impressum.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<layout src="main"/>
|
||||
<h1>Impressum!</h1>
|
||||
<p>Das ist deine Seite</p>
|
||||
73
src/Framework/View/templates/layouts/main.html
Normal file
73
src/Framework/View/templates/layouts/main.html
Normal 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>© 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>
|
||||
3
src/Framework/View/templates/test.html
Normal file
3
src/Framework/View/templates/test.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<layout src="main"/>
|
||||
<h1>Willkommen!</h1>
|
||||
<p>Das ist deine Seite</p>
|
||||
Reference in New Issue
Block a user