From 27efb7b4295647aa3e324ffab96d3f2143c39192 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 14 Mar 2026 17:15:54 +0100 Subject: [PATCH 1/9] initial commit --- bin/dev-router.php | 11 + resources/assets/css/dev.css | 196 +++++++++++ resources/package.json | 17 + .../templates/inspector/routes.sugar.php | 80 +++++ resources/vite.config.js | 20 ++ src/Application.php | 15 + src/Http/Attribute/Route.php | 43 +++ src/Http/Attribute/RoutePrefix.php | 30 ++ src/Http/Controller/InspectorController.php | 57 ++++ src/Http/Middleware/ControllerMiddleware.php | 186 +++++++++++ src/Http/Middleware/CoreAssetMiddleware.php | 70 ++++ src/Http/Routing/ControllerRouter.php | 307 ++++++++++++++++++ src/Http/Routing/ControllerViewRenderer.php | 94 ++++++ src/Http/Routing/MatchedRoute.php | 37 +++ tests/Fixture/Http/AdminController.php | 50 +++ tests/Fixture/Http/ArticleController.php | 35 ++ .../Middleware/ControllerMiddlewareTest.php | 267 +++++++++++++++ .../Http/Routing/ControllerRouterTest.php | 184 +++++++++++ .../Routing/ControllerViewRendererTest.php | 95 ++++++ 19 files changed, 1794 insertions(+) create mode 100644 resources/assets/css/dev.css create mode 100644 resources/package.json create mode 100644 resources/templates/inspector/routes.sugar.php create mode 100644 resources/vite.config.js create mode 100644 src/Http/Attribute/Route.php create mode 100644 src/Http/Attribute/RoutePrefix.php create mode 100644 src/Http/Controller/InspectorController.php create mode 100644 src/Http/Middleware/ControllerMiddleware.php create mode 100644 src/Http/Middleware/CoreAssetMiddleware.php create mode 100644 src/Http/Routing/ControllerRouter.php create mode 100644 src/Http/Routing/ControllerViewRenderer.php create mode 100644 src/Http/Routing/MatchedRoute.php create mode 100644 tests/Fixture/Http/AdminController.php create mode 100644 tests/Fixture/Http/ArticleController.php create mode 100644 tests/Unit/Http/Middleware/ControllerMiddlewareTest.php create mode 100644 tests/Unit/Http/Routing/ControllerRouterTest.php create mode 100644 tests/Unit/Http/Routing/ControllerViewRendererTest.php diff --git a/bin/dev-router.php b/bin/dev-router.php index c1f1c8c..992e699 100644 --- a/bin/dev-router.php +++ b/bin/dev-router.php @@ -10,6 +10,8 @@ use Glaze\Config\ProjectConfigurationReader; use Glaze\Http\DevPageRequestHandler; use Glaze\Http\Middleware\ContentAssetMiddleware; +use Glaze\Http\Middleware\ControllerMiddleware; +use Glaze\Http\Middleware\CoreAssetMiddleware; use Glaze\Http\Middleware\ErrorHandlingMiddleware; use Glaze\Http\Middleware\PublicAssetMiddleware; use Glaze\Http\Middleware\StaticAssetMiddleware; @@ -82,6 +84,15 @@ $queue->add($staticAssetMiddleware); $queue->add($contentAssetMiddleware); +if (!$staticMode) { + /** @var \Glaze\Http\Middleware\CoreAssetMiddleware $coreAssetMiddleware */ + $coreAssetMiddleware = $container->get(CoreAssetMiddleware::class); + /** @var \Glaze\Http\Middleware\ControllerMiddleware $controllerMiddleware */ + $controllerMiddleware = $container->get(ControllerMiddleware::class); + $queue->add($coreAssetMiddleware); + $queue->add($controllerMiddleware); +} + $response = (new Runner())->run($queue, $request, $fallbackHandler); (new ResponseEmitter())->emit($response); diff --git a/resources/assets/css/dev.css b/resources/assets/css/dev.css new file mode 100644 index 0000000..317ec61 --- /dev/null +++ b/resources/assets/css/dev.css @@ -0,0 +1,196 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --glaze-bg: #0f1117; + --glaze-surface: #1a1d27; + --glaze-border: #2d3148; + --glaze-text: #e2e8f0; + --glaze-muted: #64748b; + --glaze-accent: #6366f1; + --glaze-warn: #f59e0b; + --glaze-info: #38bdf8; + --glaze-radius: 6px; + --glaze-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +body { + font-family: var(--glaze-font); + background: var(--glaze-bg); + color: var(--glaze-text); + font-size: 14px; + line-height: 1.6; + min-height: 100vh; +} + +.glaze-container { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; +} + +/* Header */ +.glaze-header { + border-bottom: 1px solid var(--glaze-border); + padding: 12px 0; + margin-bottom: 32px; +} + +.glaze-header-inner { + display: flex; + align-items: center; + gap: 24px; +} + +.glaze-logo { + font-size: 16px; + font-weight: 700; + color: var(--glaze-accent); + letter-spacing: -0.01em; +} + +.glaze-nav { + display: flex; + gap: 4px; +} + +.glaze-nav-link { + padding: 4px 12px; + border-radius: var(--glaze-radius); + color: var(--glaze-muted); + text-decoration: none; + font-size: 13px; + transition: color 0.15s, background 0.15s; +} + +.glaze-nav-link:hover { + color: var(--glaze-text); + background: var(--glaze-surface); +} + +.glaze-nav-active { + color: var(--glaze-text); + background: var(--glaze-surface); +} + +/* Page header */ +.glaze-main { + padding-bottom: 48px; +} + +.glaze-page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.glaze-page-title { + font-size: 20px; + font-weight: 600; +} + +/* Badge */ +.glaze-badge { + background: var(--glaze-surface); + border: 1px solid var(--glaze-border); + color: var(--glaze-muted); + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; +} + +/* Table */ +.glaze-table-wrap { + overflow-x: auto; + border: 1px solid var(--glaze-border); + border-radius: var(--glaze-radius); +} + +.glaze-table { + width: 100%; + border-collapse: collapse; +} + +.glaze-table thead tr { + background: var(--glaze-surface); +} + +.glaze-table th { + padding: 10px 16px; + text-align: left; + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--glaze-muted); + border-bottom: 1px solid var(--glaze-border); +} + +.glaze-table td { + padding: 10px 16px; + border-bottom: 1px solid var(--glaze-border); + vertical-align: middle; +} + +.glaze-table tbody tr:last-child td { + border-bottom: none; +} + +.glaze-table tbody tr:hover td { + background: var(--glaze-surface); +} + +/* Link */ +.glaze-link { + color: var(--glaze-accent); + text-decoration: none; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 13px; +} + +.glaze-link:hover { + text-decoration: underline; +} + +/* Tags */ +.glaze-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: var(--glaze-surface); + border: 1px solid var(--glaze-border); + color: var(--glaze-muted); +} + +.glaze-tag-warn { + border-color: var(--glaze-warn); + color: var(--glaze-warn); +} + +.glaze-tag-info { + border-color: var(--glaze-info); + color: var(--glaze-info); +} + +.glaze-muted { + color: var(--glaze-muted); +} + +.glaze-flags { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +/* Empty state */ +.glaze-empty { + color: var(--glaze-muted); + text-align: center; + padding: 48px; +} diff --git a/resources/package.json b/resources/package.json new file mode 100644 index 0000000..573c5a4 --- /dev/null +++ b/resources/package.json @@ -0,0 +1,17 @@ +{ + "name": "glaze-core-assets", + "private": true, + "description": "Build pipeline for Glaze core dev-UI assets (inspector, routes viewer, etc.)", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.14.9", + "daisyui": "^5.0.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.0" + } +} diff --git a/resources/templates/inspector/routes.sugar.php b/resources/templates/inspector/routes.sugar.php new file mode 100644 index 0000000..788ec21 --- /dev/null +++ b/resources/templates/inspector/routes.sugar.php @@ -0,0 +1,80 @@ + $pages + */ +?> + + + + + + Glaze Inspector - Routes + + + +
+
+
+ + +
+
+ +
+
+

Content Routes

+ pages +
+ + +

No content pages discovered.

+ +
+ + + + + + + + + + + + + + + + + + + +
URL PathTitleTypeFlags
+ + urlPath, ENT_QUOTES, 'UTF-8') ?> + + title, ENT_QUOTES, 'UTF-8') ?> + type !== null): ?> + type, ENT_QUOTES, 'UTF-8') ?> + + default + + + draft): ?> + draft + + unlisted): ?> + unlisted + + virtual): ?> + virtual + +
+
+ +
+
+ + diff --git a/resources/vite.config.js b/resources/vite.config.js new file mode 100644 index 0000000..9cf8a13 --- /dev/null +++ b/resources/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss()], + server: { + port: 5174, + strictPort: true, + cors: true, + }, + build: { + outDir: 'dist', + manifest: true, + rollupOptions: { + input: { + dev: 'assets/js/dev.js', + }, + }, + }, +}); diff --git a/src/Application.php b/src/Application.php index e56007d..9b8bbb9 100644 --- a/src/Application.php +++ b/src/Application.php @@ -18,6 +18,9 @@ use Glaze\Command\ServeCommand; use Glaze\Config\BuildConfig; use Glaze\Config\NeonConfigEngine; +use Glaze\Http\Middleware\ControllerMiddleware; +use Glaze\Http\Routing\ControllerRouter; +use Glaze\Http\Routing\ControllerViewRenderer; use Glaze\Image\GlideImageTransformer; use Glaze\Image\ImagePresetResolver; use Glaze\Image\ImageTransformerInterface; @@ -98,6 +101,18 @@ static function (): ScaffoldRegistry { return new ScaffoldRegistry($scaffoldsDirectory, new ScaffoldSchemaLoader()); }, ); + + $container->addShared( + ControllerMiddleware::class, + static function () use ($container): ControllerMiddleware { + /** @var \Glaze\Http\Routing\ControllerRouter $router */ + $router = $container->get(ControllerRouter::class); + /** @var \Glaze\Http\Routing\ControllerViewRenderer $viewRenderer */ + $viewRenderer = $container->get(ControllerViewRenderer::class); + + return new ControllerMiddleware($router, $viewRenderer, $container); + }, + ); } /** diff --git a/src/Http/Attribute/Route.php b/src/Http/Attribute/Route.php new file mode 100644 index 0000000..86da5c6 --- /dev/null +++ b/src/Http/Attribute/Route.php @@ -0,0 +1,43 @@ + + */ + public readonly array $methods; + + /** + * Constructor. + * + * @param string $path URL path pattern, e.g. '/articles/{slug}'. + * @param list|string $methods HTTP method(s) accepted, defaults to GET. + */ + public function __construct( + public readonly string $path, + array|string $methods = 'GET', + ) { + $normalized = array_map('strtoupper', (array)$methods); + $this->methods = array_values($normalized); + } +} diff --git a/src/Http/Attribute/RoutePrefix.php b/src/Http/Attribute/RoutePrefix.php new file mode 100644 index 0000000..833b5a1 --- /dev/null +++ b/src/Http/Attribute/RoutePrefix.php @@ -0,0 +1,30 @@ + Template variables with key `pages` containing + * all discovered {@see \Glaze\Content\ContentPage} instances. + */ + #[Route('/routes')] + public function routes(): array + { + $pages = $this->discoveryService->discover( + $this->config->contentPath(), + $this->config->taxonomies, + $this->config->contentTypes, + ); + + return ['pages' => $pages]; + } +} diff --git a/src/Http/Middleware/ControllerMiddleware.php b/src/Http/Middleware/ControllerMiddleware.php new file mode 100644 index 0000000..262d8e0 --- /dev/null +++ b/src/Http/Middleware/ControllerMiddleware.php @@ -0,0 +1,186 @@ +discoverOnce(); + + $match = $this->router->match($request); + if (!$match instanceof MatchedRoute) { + return $handler->handle($request); + } + + /** @var object $controller */ + $controller = $this->container->get($match->controllerClass); + + $reflectionMethod = new ReflectionMethod($controller, $match->actionMethod); + $args = $this->resolveArguments($reflectionMethod, $request, $match->params); + + $result = $reflectionMethod->invokeArgs($controller, $args); + + if ($result instanceof ResponseInterface) { + return $result; + } + + if (is_array($result)) { + /** @var array $result */ + return $this->viewRenderer->render($match, $result); + } + + throw new RuntimeException(sprintf( + 'Controller action "%s::%s" must return %s or array, got %s.', + $match->controllerClass, + $match->actionMethod, + ResponseInterface::class, + get_debug_type($result), + )); + } + + /** + * Run controller discovery exactly once per middleware instance lifetime. + * + * Scans the package's own src/Http/Controller/ directory by default, or the + * explicitly configured $controllersDirectory when provided. + */ + private function discoverOnce(): void + { + if ($this->discovered) { + return; + } + + $dir = $this->controllersDirectory !== '' + ? $this->controllersDirectory + : dirname(__DIR__) . '/Controller'; + + $this->router->discover($dir); + $this->discovered = true; + } + + /** + * Resolve the argument list for an action method. + * + * Resolution order for each parameter: + * 1. ServerRequestInterface — injected from the current PSR-7 request + * 2. Path parameter — matched by parameter name from the route + * 3. Named service — resolved by fully-qualified type from the container + * 4. Default value — used when the parameter is optional + * + * @param \ReflectionMethod $method Reflected action method. + * @param \Psr\Http\Message\ServerRequestInterface $request Current request. + * @param array $params Path parameters extracted from the route. + * @return array Positional argument list ready for invokeArgs. + * @throws \RuntimeException When a required parameter cannot be resolved. + */ + protected function resolveArguments( + ReflectionMethod $method, + ServerRequestInterface $request, + array $params, + ): array { + $args = []; + + foreach ($method->getParameters() as $parameter) { + $name = $parameter->getName(); + $type = $parameter->getType(); + + // 1. PSR-7 request by type. + if ( + $type instanceof ReflectionNamedType + && !$type->isBuiltin() + && is_a($type->getName(), ServerRequestInterface::class, true) + ) { + $args[] = $request; + continue; + } + + // 2. Path parameter by name. + if (array_key_exists($name, $params)) { + $args[] = $params[$name]; + continue; + } + + // 3. Service from the DI container by type. + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $args[] = $this->container->get($type->getName()); + continue; + } + + // 4. Optional parameter default. + if ($parameter->isOptional()) { + $args[] = $parameter->getDefaultValue(); + continue; + } + + throw new RuntimeException(sprintf( + 'Cannot resolve parameter "$%s" for action "%s::%s": no type hint, path parameter, or default value.', + $name, + $method->getDeclaringClass()->getName(), + $method->getName(), + )); + } + + return $args; + } +} diff --git a/src/Http/Middleware/CoreAssetMiddleware.php b/src/Http/Middleware/CoreAssetMiddleware.php new file mode 100644 index 0000000..255e867 --- /dev/null +++ b/src/Http/Middleware/CoreAssetMiddleware.php @@ -0,0 +1,70 @@ +assetsRootPath = dirname(__DIR__, 3) . '/resources/assets'; + } + + /** + * @inheritDoc + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $path = $request->getUri()->getPath(); + + if (!str_starts_with($path, self::URL_PREFIX . '/')) { + return $handler->handle($request); + } + + $relativePath = substr($path, strlen(self::URL_PREFIX)); + + $response = $this->assetResponder->createFileResponse($this->assetsRootPath, $relativePath); + + if ($response instanceof Response) { + return $response; + } + + return $handler->handle($request); + } +} diff --git a/src/Http/Routing/ControllerRouter.php b/src/Http/Routing/ControllerRouter.php new file mode 100644 index 0000000..57a9212 --- /dev/null +++ b/src/Http/Routing/ControllerRouter.php @@ -0,0 +1,307 @@ +discover('/path/to/project/controllers'); + * $match = $router->match($request); // MatchedRoute|null + */ +final class ControllerRouter +{ + /** + * Compiled route table. + * + * Each entry describes one route: + * - pattern: PCRE regex with named captures for path parameters + * - methods: list of uppercase HTTP methods accepted + * - class: fully-qualified controller class name + * - method: action method name + * - controller: short controller name for view resolution + * - action: action name for view resolution + * + * @var list, class: class-string, method: string, controller: string, action: string}> + */ + private array $routes = []; + + /** + * Scan a directory for controller PHP files and register their routes. + * + * Each PHP file in $directory is required and inspected via reflection. + * Classes annotated with #[Route] on their methods (and optionally + * #[RoutePrefix] on the class) are registered in the route table. + * + * @param string $directory Absolute path to the controllers directory. + */ + public function discover(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + ); + + /** @var \SplFileInfo $fileInfo */ + foreach ($iterator as $fileInfo) { + if ($fileInfo->getExtension() !== 'php') { + continue; + } + + $this->registerFile($fileInfo->getPathname()); + } + } + + /** + * Match a PSR-7 request against the registered routes. + * + * Returns the first matching MatchedRoute, or null when no routes match + * the request path and method combination. + * + * @param \Psr\Http\Message\ServerRequestInterface $request Incoming request. + */ + public function match(ServerRequestInterface $request): ?MatchedRoute + { + $path = $request->getUri()->getPath(); + $path = $path !== '' ? $path : '/'; + + $method = strtoupper($request->getMethod()); + + foreach ($this->routes as $route) { + if (!preg_match($route['pattern'], $path, $matches)) { + continue; + } + + if (!in_array($method, $route['methods'], true)) { + continue; + } + + $params = array_filter( + $matches, + static fn(int|string $key): bool => is_string($key), + ARRAY_FILTER_USE_KEY, + ); + + return new MatchedRoute( + controllerClass: $route['class'], + actionMethod: $route['method'], + params: array_map('strval', $params), + controllerName: $route['controller'], + actionName: $route['action'], + ); + } + + return null; + } + + /** + * Return all registered routes (useful for debugging/testing). + * + * @return list, class: class-string, method: string, controller: string, action: string}> + */ + public function routes(): array + { + return $this->routes; + } + + /** + * Require a PHP file and register the controller class it defines. + * + * The fully-qualified class name is extracted by parsing PHP tokens so + * that classes already loaded by the autoloader are still discovered. + * + * @param string $filePath Absolute path to the PHP file. + */ + private function registerFile(string $filePath): void + { + $className = $this->extractClassName($filePath); + if ($className === null) { + return; + } + + if (!class_exists($className)) { + require_once $filePath; + } + + $this->registerClass($className); + } + + /** + * Extract the fully-qualified class name from a PHP source file. + * + * Parses PHP tokens to find the first namespace declaration and the first + * class (or final class) declaration. Returns null when no class is found. + * + * @param string $filePath Absolute path to the PHP file. + */ + private function extractClassName(string $filePath): ?string + { + $source = file_get_contents($filePath); + if ($source === false) { + return null; + } + + $tokens = token_get_all($source); + $count = count($tokens); + $namespace = ''; + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + if (!is_array($token)) { + continue; + } + + if ($token[0] === T_NAMESPACE) { + $i++; + $ns = ''; + while ($i < $count) { + $t = $tokens[$i]; + $namespaceParts = [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED]; + if (is_array($t) && in_array($t[0], $namespaceParts, true)) { + $ns .= $t[1]; + $i++; + continue; + } + + if (!is_array($t) && $t === '{') { + break; + } + + if (!is_array($t) && $t === ';') { + break; + } + + $i++; + } + + $namespace = $ns; + } + + if ($token[0] === T_CLASS) { + // Skip whitespace to find the class name token. + $j = $i + 1; + while ($j < $count && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) { + $j++; + } + + if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) { + $shortName = $tokens[$j][1]; + + return $namespace !== '' ? $namespace . '\\' . $shortName : $shortName; + } + } + } + + return null; + } + + /** + * Inspect a class for route attributes and register each found route. + * + * @param class-string|string $className Fully-qualified class name. + */ + private function registerClass(string $className): void + { + if (!class_exists($className)) { + return; + } + + $reflection = new ReflectionClass($className); + + if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) { + return; + } + + $prefix = ''; + $prefixAttributes = $reflection->getAttributes(RoutePrefix::class); + if ($prefixAttributes !== []) { + /** @var \Glaze\Http\Attribute\RoutePrefix $routePrefixAttr */ + $routePrefixAttr = $prefixAttributes[0]->newInstance(); + $prefix = rtrim($routePrefixAttr->prefix, '/'); + } + + $controllerName = $this->resolveControllerName($reflection->getShortName()); + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $routeAttributes = $method->getAttributes(Route::class); + foreach ($routeAttributes as $attributeRef) { + /** @var \Glaze\Http\Attribute\Route $routeAttr */ + $routeAttr = $attributeRef->newInstance(); + + $fullPath = $prefix . '/' . ltrim($routeAttr->path, '/'); + + // Normalise double slashes that arise when prefix ends at '/' and + // path also starts with '/', except for the root path. + $normalised = preg_replace('#/{2,}#', '/', $fullPath); + $fullPath = $normalised ?? $fullPath; + if ($fullPath === '') { + $fullPath = '/'; + } + + $this->routes[] = [ + 'pattern' => $this->buildPattern($fullPath), + 'methods' => $routeAttr->methods, + 'class' => $reflection->getName(), + 'method' => $method->getName(), + 'controller' => $controllerName, + 'action' => strtolower($method->getName()), + ]; + } + } + } + + /** + * Derive the short, lowercase controller name from a class name. + * + * Strips the "Controller" suffix if present. + * + * @param string $shortName Short (unqualified) class name. + */ + private function resolveControllerName(string $shortName): string + { + $name = preg_replace('/Controller$/i', '', $shortName); + + return strtolower($name ?? $shortName); + } + + /** + * Convert a route path into a PCRE pattern with named captures. + * + * Path parameters in the form {name} are converted to named captures + * (?P[^/]+). + * + * @param string $path Route path, e.g. '/articles/{slug}'. + * @throws \RuntimeException When the compiled pattern is invalid. + */ + private function buildPattern(string $path): string + { + $escaped = preg_quote($path, '#'); + $pattern = preg_replace('/\\\\{([a-zA-Z_]\w*)\\\\}/', '(?P<$1>[^/]+)', $escaped); + + if (!is_string($pattern)) { + throw new RuntimeException(sprintf('Failed to compile route pattern for path "%s".', $path)); + } + + return '#^' . $pattern . '$#'; + } +} diff --git a/src/Http/Routing/ControllerViewRenderer.php b/src/Http/Routing/ControllerViewRenderer.php new file mode 100644 index 0000000..cad77e6 --- /dev/null +++ b/src/Http/Routing/ControllerViewRenderer.php @@ -0,0 +1,94 @@ +render($matchedRoute, ['pages' => $pages]); + */ +final class ControllerViewRenderer +{ + /** + * Absolute path to the template directory used for resolution. + */ + private string $templateDirectory; + + /** + * Constructor. + * + * @param \Glaze\Config\BuildConfig $config Build configuration (cache path + site config). + * @param \Glaze\Support\ResourcePathRewriter $resourcePathRewriter Shared path rewriter. + * @param string|null $templateDirectory Override template directory for testing. Defaults to + * the package's resources/templates/ when null. + */ + public function __construct( + private BuildConfig $config, + private ResourcePathRewriter $resourcePathRewriter, + ?string $templateDirectory = null, + ) { + $this->templateDirectory = $templateDirectory ?? dirname(__DIR__, 3) . '/resources/templates'; + } + + /** + * Render a controller action and return a 200 HTML response. + * + * @param \Glaze\Http\Routing\MatchedRoute $route The matched route to render. + * @param array $data Template variables provided by the action. + */ + public function render(MatchedRoute $route, array $data): ResponseInterface + { + $templateName = $route->controllerName . '/' . $route->actionName; + + $renderer = new SugarPageRenderer( + templatePath: $this->templateDirectory, + cachePath: $this->config->cachePath(CachePath::Sugar), + template: $templateName, + siteConfig: $this->config->site, + resourcePathRewriter: $this->resourcePathRewriter, + templateVite: new TemplateViteOptions(), + debug: true, + ); + + $html = $renderer->render($data); + + return (new Response(['charset' => 'UTF-8'])) + ->withStatus(200) + ->withHeader('Content-Type', 'text/html; charset=UTF-8') + ->withStringBody($html); + } + + /** + * Check whether a template file exists for the given controller and action. + * + * @param string $controller Short controller name. + * @param string $action Action name. + */ + public function hasTemplate(string $controller, string $action): bool + { + $templateFile = $this->templateDirectory . '/' . $controller . '/' . $action . '.sugar.php'; + + return is_file($templateFile); + } +} diff --git a/src/Http/Routing/MatchedRoute.php b/src/Http/Routing/MatchedRoute.php new file mode 100644 index 0000000..c82246c --- /dev/null +++ b/src/Http/Routing/MatchedRoute.php @@ -0,0 +1,37 @@ +controllerName // 'admin' (derived from AdminController) + * $route->actionName // 'edit' + * $route->params // ['slug' => 'my-article'] + */ +final readonly class MatchedRoute +{ + /** + * Constructor. + * + * @param class-string $controllerClass Fully-qualified controller class name. + * @param string $actionMethod Method name on the controller. + * @param array $params Captured path parameters keyed by name. + * @param string $controllerName Lowercased short controller name (without "Controller" suffix). + * @param string $actionName Action method name in lowercase. + */ + public function __construct( + public string $controllerClass, + public string $actionMethod, + public array $params, + public string $controllerName, + public string $actionName, + ) { + } +} diff --git a/tests/Fixture/Http/AdminController.php b/tests/Fixture/Http/AdminController.php new file mode 100644 index 0000000..d7cfe24 --- /dev/null +++ b/tests/Fixture/Http/AdminController.php @@ -0,0 +1,50 @@ + + */ + #[Route('/dashboard')] + public function dashboard(): array + { + return ['page' => 'dashboard']; + } + + /** + * Edit action — GET or POST /admin/articles/{slug}. + * + * @param string $slug Article slug path parameter. + * @param \Psr\Http\Message\ServerRequestInterface $request Current request. + * @return array + */ + #[Route('/articles/{slug}', methods: ['GET', 'POST'])] + public function edit(string $slug, ServerRequestInterface $request): array + { + return ['slug' => $slug, 'method' => $request->getMethod()]; + } + + /** + * Delete action — returns a ResponseInterface directly. + */ + #[Route('/articles/{id}/delete', methods: 'POST')] + public function delete(): ResponseInterface + { + return (new Response())->withStatus(204); + } +} diff --git a/tests/Fixture/Http/ArticleController.php b/tests/Fixture/Http/ArticleController.php new file mode 100644 index 0000000..aaf994c --- /dev/null +++ b/tests/Fixture/Http/ArticleController.php @@ -0,0 +1,35 @@ + + */ + #[Route('/articles')] + public function index(): array + { + return ['articles' => []]; + } + + /** + * Show action — GET /articles/{slug}. + * + * @param string $slug Article slug path parameter. + * @return array + */ + #[Route('/articles/{slug}')] + public function show(string $slug): array + { + return ['slug' => $slug]; + } +} diff --git a/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php b/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php new file mode 100644 index 0000000..40788aa --- /dev/null +++ b/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php @@ -0,0 +1,267 @@ +createProjectRoot(), true); + + $middleware = new ControllerMiddleware( + $router, + $this->makeViewRenderer($config), + $this->container(), + $this->createTempDirectory(), + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/no-match'); + $handler = $this->fallbackHandler(404, 'fallback'); + + $response = $middleware->process($request, $handler); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame('fallback', (string)$response->getBody()); + } + + /** + * Ensure a matched route that returns ResponseInterface is returned directly. + */ + public function testProcessReturnsResponseDirectlyFromAction(): void + { + $projectRoot = $this->createProjectRoot(); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $controllersDir = $projectRoot . '/controllers'; + mkdir($controllersDir, 0755, true); + file_put_contents($controllersDir . '/PingController.php', <<<'PHP' + 'UTF-8']))->withStatus(200)->withStringBody('pong'); + } + } + PHP); + + $router = new ControllerRouter(); + $middleware = new ControllerMiddleware( + $router, + $this->makeViewRenderer($config), + $this->container(), + $controllersDir, + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/ping'); + $response = $middleware->process($request, $this->fallbackHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('pong', (string)$response->getBody()); + } + + /** + * Ensure path parameters are injected into action method arguments. + */ + public function testProcessInjectsPathParametersIntoAction(): void + { + $projectRoot = $this->createProjectRoot(); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $controllersDir = $projectRoot . '/controllers'; + mkdir($controllersDir, 0755, true); + file_put_contents($controllersDir . '/SlugController.php', <<<'PHP' + 'UTF-8']))->withStatus(200)->withStringBody($slug); + } + } + PHP); + + $router = new ControllerRouter(); + $middleware = new ControllerMiddleware( + $router, + $this->makeViewRenderer($config), + $this->container(), + $controllersDir, + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/items/hello-world'); + $response = $middleware->process($request, $this->fallbackHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('hello-world', (string)$response->getBody()); + } + + /** + * Ensure the PSR-7 request is injected when the action type-hints it. + */ + public function testProcessInjectsRequestByType(): void + { + $projectRoot = $this->createProjectRoot(); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $controllersDir = $projectRoot . '/controllers'; + mkdir($controllersDir, 0755, true); + file_put_contents($controllersDir . '/MethodController.php', <<<'PHP' + 'UTF-8']))->withStatus(200)->withStringBody($request->getMethod()); + } + } + PHP); + + $router = new ControllerRouter(); + $middleware = new ControllerMiddleware( + $router, + $this->makeViewRenderer($config), + $this->container(), + $controllersDir, + ); + + $postRequest = (new ServerRequestFactory())->createServerRequest('POST', '/method'); + $response = $middleware->process($postRequest, $this->fallbackHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('POST', (string)$response->getBody()); + } + + /** + * Ensure controller discovery only runs once even across multiple requests. + * + * A temp directory is created with one controller file. Both requests match + * the same route, confirming discovery persisted after the first request. + */ + public function testDiscoveryRunsOnlyOnce(): void + { + $projectRoot = $this->createProjectRoot(); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $controllersDir = $projectRoot . '/controllers'; + mkdir($controllersDir, 0755, true); + file_put_contents($controllersDir . '/OnceController.php', <<<'PHP' + 'UTF-8']))->withStatus(200)->withStringBody('ok'); + } + } + PHP); + + $router = new ControllerRouter(); + $middleware = new ControllerMiddleware( + $router, + $this->makeViewRenderer($config), + $this->container(), + $controllersDir, + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/once'); + + // First request — triggers discovery. + $response1 = $middleware->process($request, $this->fallbackHandler()); + $this->assertSame(200, $response1->getStatusCode()); + + // Second request — discovery must NOT clear and re-run (route still matched). + $response2 = $middleware->process($request, $this->fallbackHandler()); + $this->assertSame(200, $response2->getStatusCode()); + } + + /** + * Create a minimal project root directory with required subdirectories. + */ + protected function createProjectRoot(): string + { + $dir = $this->createTempDirectory(); + mkdir($dir . '/content', 0755, true); + + return $dir; + } + + /** + * Create a ControllerViewRenderer bound to the given config. + * + * @param \Glaze\Config\BuildConfig $config Build configuration. + */ + protected function makeViewRenderer(BuildConfig $config): ControllerViewRenderer + { + return new ControllerViewRenderer($config, $this->service(ResourcePathRewriter::class)); + } + + /** + * Create a fallback request handler that returns a fixed status and body. + * + * @param int $status HTTP status code. + * @param string $body Response body. + */ + protected function fallbackHandler(int $status = 404, string $body = 'fallback'): RequestHandlerInterface + { + return new class ($status, $body) implements RequestHandlerInterface { + /** + * Constructor. + * + * @param int $status HTTP status code. + * @param string $body Response body. + */ + public function __construct(private int $status, private string $body) + { + } + + /** + * @inheritDoc + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return (new Response(['charset' => 'UTF-8'])) + ->withStatus($this->status) + ->withStringBody($this->body); + } + }; + } +} diff --git a/tests/Unit/Http/Routing/ControllerRouterTest.php b/tests/Unit/Http/Routing/ControllerRouterTest.php new file mode 100644 index 0000000..f31cb09 --- /dev/null +++ b/tests/Unit/Http/Routing/ControllerRouterTest.php @@ -0,0 +1,184 @@ +discover(dirname(__DIR__, 3) . '/Fixture/Http'); + + return $router; + } + + /** + * Ensure a simple prefixed route is matched. + */ + public function testMatchReturnsPrefixedRoute(): void + { + $router = $this->makeRouter(); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/admin/dashboard'); + + $match = $router->match($request); + + $this->assertInstanceOf(MatchedRoute::class, $match); + $this->assertSame(AdminController::class, $match->controllerClass); + $this->assertSame('dashboard', $match->actionMethod); + $this->assertSame('admin', $match->controllerName); + $this->assertSame('dashboard', $match->actionName); + $this->assertSame([], $match->params); + } + + /** + * Ensure path parameters are extracted and mapped by name. + */ + public function testMatchExtractsPathParameters(): void + { + $router = $this->makeRouter(); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/admin/articles/my-article'); + + $match = $router->match($request); + + $this->assertInstanceOf(MatchedRoute::class, $match); + $this->assertSame('my-article', $match->params['slug']); + } + + /** + * Ensure matching works for routes without a prefix. + */ + public function testMatchesUnprefixedRoute(): void + { + $router = $this->makeRouter(); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/articles'); + + $match = $router->match($request); + + $this->assertInstanceOf(MatchedRoute::class, $match); + $this->assertSame(ArticleController::class, $match->controllerClass); + $this->assertSame('index', $match->actionMethod); + $this->assertSame('article', $match->controllerName); + } + + /** + * Ensure incorrect HTTP method returns null. + */ + public function testMatchReturnsNullForWrongMethod(): void + { + $router = $this->makeRouter(); + // /admin/dashboard only accepts GET + $request = (new ServerRequestFactory())->createServerRequest('DELETE', '/admin/dashboard'); + + $this->assertNotInstanceOf(MatchedRoute::class, $router->match($request)); + } + + /** + * Ensure no match is returned for an unknown path. + */ + public function testMatchReturnsNullForUnknownPath(): void + { + $router = $this->makeRouter(); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/no/such/path'); + + $this->assertNotInstanceOf(MatchedRoute::class, $router->match($request)); + } + + /** + * Ensure multi-method attribute registration works (GET and POST on same route). + */ + public function testMatchAcceptsAllDeclaredHttpMethods(): void + { + $router = $this->makeRouter(); + + $getRequest = (new ServerRequestFactory())->createServerRequest('GET', '/admin/articles/slug-a'); + $postRequest = (new ServerRequestFactory())->createServerRequest('POST', '/admin/articles/slug-a'); + + $this->assertInstanceOf(MatchedRoute::class, $router->match($getRequest)); + $this->assertInstanceOf(MatchedRoute::class, $router->match($postRequest)); + } + + /** + * Ensure methods passed as a plain string are normalised to uppercase. + */ + public function testMatchMethodsNormalisedToUppercase(): void + { + $router = $this->makeRouter(); + // /admin/articles/{id}/delete accepts 'POST' (declared as 'POST' string) + $request = (new ServerRequestFactory())->createServerRequest('POST', '/admin/articles/42/delete'); + + $match = $router->match($request); + + $this->assertInstanceOf(MatchedRoute::class, $match); + $this->assertSame('delete', $match->actionName); + } + + /** + * Ensure discover() silently does nothing for non-existent directories. + */ + public function testDiscoverIgnoresMissingDirectory(): void + { + $router = new ControllerRouter(); + $router->discover('/tmp/glaze-non-existent-controllers-dir'); + + $this->assertSame([], $router->routes()); + } + + /** + * Ensure discover() registers routes found in a freshly-created temp dir. + */ + public function testDiscoverFromTempDirectory(): void + { + $dir = $this->createTempDirectory(); + file_put_contents($dir . '/HelloController.php', <<<'PHP' + discover($dir); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/hello'); + $match = $router->match($request); + + $this->assertInstanceOf(MatchedRoute::class, $match); + $this->assertSame('hello', $match->controllerName); + $this->assertSame('index', $match->actionName); + } + + /** + * Ensure the controller name derived from a 'FooController' class is 'foo'. + */ + public function testControllerNameStripsSuffix(): void + { + $router = $this->makeRouter(); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/articles/hello'); + + $match = $router->match($request); + + $this->assertInstanceOf(MatchedRoute::class, $match); + $this->assertSame('article', $match->controllerName); + } +} diff --git a/tests/Unit/Http/Routing/ControllerViewRendererTest.php b/tests/Unit/Http/Routing/ControllerViewRendererTest.php new file mode 100644 index 0000000..7b69051 --- /dev/null +++ b/tests/Unit/Http/Routing/ControllerViewRendererTest.php @@ -0,0 +1,95 @@ +createTempDirectory(); + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/content', 0755, true); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $renderer = $this->makeRenderer($config, $templateDir); + + $this->assertFalse($renderer->hasTemplate('inspector', 'nonexistent')); + } + + /** + * Ensure hasTemplate returns true when the template exists in the configured directory. + */ + public function testHasTemplateReturnsTrueWhenTemplateExists(): void + { + $templateDir = $this->createTempDirectory(); + mkdir($templateDir . '/inspector', 0755, true); + file_put_contents($templateDir . '/inspector/routes.sugar.php', '

routes

'); + + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/content', 0755, true); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $renderer = $this->makeRenderer($config, $templateDir); + + $this->assertTrue($renderer->hasTemplate('inspector', 'routes')); + } + + /** + * Ensure render returns a 200 HTML response with the template output. + */ + public function testRenderRendersTemplate(): void + { + $templateDir = $this->createTempDirectory(); + mkdir($templateDir . '/test', 0755, true); + file_put_contents($templateDir . '/test/hello.sugar.php', ''); + + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/content', 0755, true); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $renderer = $this->makeRenderer($config, $templateDir); + + $route = new MatchedRoute( + controllerClass: self::class, + actionMethod: 'hello', + params: [], + controllerName: 'test', + actionName: 'hello', + ); + + $response = $renderer->render($route, ['greeting' => 'Hello from package!']); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('text/html', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('Hello from package!', (string)$response->getBody()); + } + + /** + * Instantiate a renderer for the given config and optional template directory. + * + * @param \Glaze\Config\BuildConfig $config Build configuration. + * @param string|null $templateDirectory Optional override template directory. + */ + protected function makeRenderer(BuildConfig $config, ?string $templateDirectory = null): ControllerViewRenderer + { + return new ControllerViewRenderer($config, $this->service(ResourcePathRewriter::class), $templateDirectory); + } +} From 5b13cc6c3673acc6a27bb4b1bccd349886c9b62f Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 14 Mar 2026 18:24:42 +0100 Subject: [PATCH 2/9] basepath fixes --- .../templates/inspector/routes.sugar.php | 81 ++++----- src/Application.php | 4 +- src/Http/Middleware/ControllerMiddleware.php | 10 ++ src/Http/Middleware/CoreAssetMiddleware.php | 25 ++- .../Middleware/ControllerMiddlewareTest.php | 5 + .../Middleware/CoreAssetMiddlewareTest.php | 168 ++++++++++++++++++ 6 files changed, 238 insertions(+), 55 deletions(-) create mode 100644 tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php diff --git a/resources/templates/inspector/routes.sugar.php b/resources/templates/inspector/routes.sugar.php index 788ec21..b3ce224 100644 --- a/resources/templates/inspector/routes.sugar.php +++ b/resources/templates/inspector/routes.sugar.php @@ -28,52 +28,41 @@ pages - -

No content pages discovered.

- -
- - - - - - - - - - - - - - - - - - - -
URL PathTitleTypeFlags
- - urlPath, ENT_QUOTES, 'UTF-8') ?> - - title, ENT_QUOTES, 'UTF-8') ?> - type !== null): ?> - type, ENT_QUOTES, 'UTF-8') ?> - - default - - - draft): ?> - draft - - unlisted): ?> - unlisted - - virtual): ?> - virtual - -
-
- +

No content pages discovered.

+ +
+ + + + + + + + + + + + + + + + + +
URL PathTitleTypeFlags
+ + urlPath ?> + + title ?> + type ?> + default + +
+ draft + unlisted + virtual +
+
+
diff --git a/src/Application.php b/src/Application.php index 9b8bbb9..58a434e 100644 --- a/src/Application.php +++ b/src/Application.php @@ -109,8 +109,10 @@ static function () use ($container): ControllerMiddleware { $router = $container->get(ControllerRouter::class); /** @var \Glaze\Http\Routing\ControllerViewRenderer $viewRenderer */ $viewRenderer = $container->get(ControllerViewRenderer::class); + /** @var \Glaze\Config\BuildConfig $config */ + $config = $container->get(BuildConfig::class); - return new ControllerMiddleware($router, $viewRenderer, $container); + return new ControllerMiddleware($router, $viewRenderer, $container, $config); }, ); } diff --git a/src/Http/Middleware/ControllerMiddleware.php b/src/Http/Middleware/ControllerMiddleware.php index 262d8e0..6f4cc27 100644 --- a/src/Http/Middleware/ControllerMiddleware.php +++ b/src/Http/Middleware/ControllerMiddleware.php @@ -4,6 +4,8 @@ namespace Glaze\Http\Middleware; use Cake\Core\ContainerInterface; +use Glaze\Config\BuildConfig; +use Glaze\Http\Concern\BasePathAwareTrait; use Glaze\Http\Routing\ControllerRouter; use Glaze\Http\Routing\ControllerViewRenderer; use Glaze\Http\Routing\MatchedRoute; @@ -39,6 +41,8 @@ */ final class ControllerMiddleware implements MiddlewareInterface { + use BasePathAwareTrait; + /** * Whether controller discovery has run for this request cycle. */ @@ -50,6 +54,7 @@ final class ControllerMiddleware implements MiddlewareInterface * @param \Glaze\Http\Routing\ControllerRouter $router Controller router. * @param \Glaze\Http\Routing\ControllerViewRenderer $viewRenderer View renderer for array returns. * @param \Cake\Core\ContainerInterface $container DI container for controller and service resolution. + * @param \Glaze\Config\BuildConfig $config Build configuration (provides site basePath). * @param string $controllersDirectory Absolute path to the controllers directory to scan. Defaults to * the package's own src/Http/Controller/ when empty. */ @@ -57,6 +62,7 @@ public function __construct( protected ControllerRouter $router, protected ControllerViewRenderer $viewRenderer, protected ContainerInterface $container, + protected BuildConfig $config, private string $controllersDirectory = '', ) { } @@ -68,6 +74,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface { $this->discoverOnce(); + $request = $request->withUri( + $request->getUri()->withPath($this->stripBasePathFromRequestPath($request->getUri()->getPath())), + ); + $match = $this->router->match($request); if (!$match instanceof MatchedRoute) { return $handler->handle($request); diff --git a/src/Http/Middleware/CoreAssetMiddleware.php b/src/Http/Middleware/CoreAssetMiddleware.php index 255e867..f60ac50 100644 --- a/src/Http/Middleware/CoreAssetMiddleware.php +++ b/src/Http/Middleware/CoreAssetMiddleware.php @@ -4,7 +4,9 @@ namespace Glaze\Http\Middleware; use Cake\Http\Response; +use Glaze\Config\BuildConfig; use Glaze\Http\AssetResponder; +use Glaze\Http\Concern\BasePathAwareTrait; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -17,17 +19,21 @@ * that glaze's dev-UI (inspector, routes viewer, etc.) works without any * project-level configuration. * - * This middleware intentionally does NOT extend AbstractAssetMiddleware. - * The /_glaze/ prefix is absolute and must never have a project base-path - * stripped from it. + * When a project configures a basePath (e.g. `/myapp`), Sugar templates emit + * asset hrefs prefixed with that basePath (e.g. `/myapp/_glaze/assets/...`). + * This middleware strips the project basePath before matching so that + * `/_glaze/assets/` is always resolved correctly regardless of deployment prefix. * * Example: - * GET /_glaze/assets/css/dev.css → {package}/resources/assets/css/dev.css + * GET /_glaze/assets/css/dev.css → {package}/resources/assets/css/dev.css + * GET /myapp/_glaze/assets/css/dev.css → same (basePath stripped first) */ final class CoreAssetMiddleware implements MiddlewareInterface { + use BasePathAwareTrait; + /** - * URL prefix handled by this middleware. + * URL prefix handled by this middleware (after basePath is stripped). */ private const URL_PREFIX = '/_glaze/assets'; @@ -39,10 +45,13 @@ final class CoreAssetMiddleware implements MiddlewareInterface /** * Constructor. * + * @param \Glaze\Config\BuildConfig $config Build configuration (provides site basePath). * @param \Glaze\Http\AssetResponder $assetResponder Asset file responder. */ - public function __construct(private AssetResponder $assetResponder) - { + public function __construct( + protected BuildConfig $config, + private AssetResponder $assetResponder, + ) { $this->assetsRootPath = dirname(__DIR__, 3) . '/resources/assets'; } @@ -51,7 +60,7 @@ public function __construct(private AssetResponder $assetResponder) */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $path = $request->getUri()->getPath(); + $path = $this->stripBasePathFromRequestPath($request->getUri()->getPath()); if (!str_starts_with($path, self::URL_PREFIX . '/')) { return $handler->handle($request); diff --git a/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php b/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php index 40788aa..47bca77 100644 --- a/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php +++ b/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php @@ -37,6 +37,7 @@ public function testProcessPassesUnmatchedRequestToNextHandler(): void $router, $this->makeViewRenderer($config), $this->container(), + $config, $this->createTempDirectory(), ); @@ -78,6 +79,7 @@ public function ping(): ResponseInterface { $router, $this->makeViewRenderer($config), $this->container(), + $config, $controllersDir, ); @@ -117,6 +119,7 @@ public function show(string $slug): ResponseInterface { $router, $this->makeViewRenderer($config), $this->container(), + $config, $controllersDir, ); @@ -157,6 +160,7 @@ public function check(ServerRequestInterface $request): ResponseInterface { $router, $this->makeViewRenderer($config), $this->container(), + $config, $controllersDir, ); @@ -199,6 +203,7 @@ public function index(): ResponseInterface { $router, $this->makeViewRenderer($config), $this->container(), + $config, $controllersDir, ); diff --git a/tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php b/tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php new file mode 100644 index 0000000..29d423d --- /dev/null +++ b/tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php @@ -0,0 +1,168 @@ +createTempDirectory(); + mkdir($assetsDir . '/css', 0755, true); + file_put_contents($assetsDir . '/css/dev.css', 'body { color: red; }'); + + $middleware = $this->makeMiddleware($assetsDir); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/_glaze/assets/css/dev.css'); + + $response = $middleware->process($request, $this->fallbackHandler(404)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('text/css', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('body { color: red; }', (string)$response->getBody()); + } + + /** + * Ensure a request for a missing asset falls through to the next handler. + */ + public function testProcessPassesThroughWhenAssetNotFound(): void + { + $assetsDir = $this->createTempDirectory(); + $middleware = $this->makeMiddleware($assetsDir); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/_glaze/assets/css/missing.css'); + + $response = $middleware->process($request, $this->fallbackHandler(404, 'not found')); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame('not found', (string)$response->getBody()); + } + + /** + * Ensure non-/_glaze/ requests are forwarded to the next handler. + */ + public function testProcessPassesThroughUnrelatedRequest(): void + { + $assetsDir = $this->createTempDirectory(); + $middleware = $this->makeMiddleware($assetsDir); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/some/other/path'); + + $response = $middleware->process($request, $this->fallbackHandler(404, 'other')); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame('other', (string)$response->getBody()); + } + + /** + * Ensure requests with a project basePath prefix are correctly resolved. + * + * When a project configures basePath (e.g. "/myapp"), Sugar templates emit + * asset hrefs prefixed with it (e.g. /myapp/_glaze/assets/css/dev.css). + * The middleware must strip the basePath and serve the file. + */ + public function testProcessStripsBasePathBeforeMatching(): void + { + $assetsDir = $this->createTempDirectory(); + mkdir($assetsDir . '/css', 0755, true); + file_put_contents($assetsDir . '/css/dev.css', '.glaze {}'); + + $middleware = $this->makeMiddleware($assetsDir, '/myapp'); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/myapp/_glaze/assets/css/dev.css'); + + $response = $middleware->process($request, $this->fallbackHandler(404)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('.glaze {}', (string)$response->getBody()); + } + + /** + * Ensure that with a basePath set, a bare /_glaze/assets/ request also still works. + */ + public function testProcessServesAssetWithBasePathSetButNoPrefix(): void + { + $assetsDir = $this->createTempDirectory(); + mkdir($assetsDir . '/css', 0755, true); + file_put_contents($assetsDir . '/css/dev.css', '.glaze {}'); + + $middleware = $this->makeMiddleware($assetsDir, '/myapp'); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/_glaze/assets/css/dev.css'); + + $response = $middleware->process($request, $this->fallbackHandler(404)); + + // Without the basePath prefix the path does not start with /myapp so stripBasePath + // returns it unchanged, then the /_glaze/assets/ prefix still matches. + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Build a CoreAssetMiddleware with the given assets directory root overridden via reflection. + * + * @param string $assetsDir Temporary assets directory acting as resources/assets/. + * @param string|null $basePath Optional site basePath (simulates glaze.neon site.basePath). + */ + protected function makeMiddleware(string $assetsDir, ?string $basePath = null): CoreAssetMiddleware + { + $projectRoot = $this->createTempDirectory(); + $config = new BuildConfig( + projectRoot: $projectRoot, + site: new SiteConfig(basePath: $basePath), + ); + + $middleware = new CoreAssetMiddleware($config, new AssetResponder()); + + $reflection = new ReflectionProperty(CoreAssetMiddleware::class, 'assetsRootPath'); + $reflection->setValue($middleware, $assetsDir); + + return $middleware; + } + + /** + * Build a simple fallback handler that returns the given status and body. + * + * @param int $status HTTP status code. + * @param string $body Response body. + */ + protected function fallbackHandler(int $status = 404, string $body = 'fallback'): RequestHandlerInterface + { + return new class ($status, $body) implements RequestHandlerInterface { + /** + * Constructor. + * + * @param int $status HTTP status code. + * @param string $body Response body. + */ + public function __construct(private int $status, private string $body) + { + } + + /** + * @inheritDoc + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return (new Response(['charset' => 'UTF-8'])) + ->withStatus($this->status) + ->withStringBody($this->body); + } + }; + } +} From 8fff4dbd3fefa601e7eeb67a384cf618165ccd8d Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 14 Mar 2026 18:49:37 +0100 Subject: [PATCH 3/9] feat: handle review feedback - basePath forwarding, type coercion, inspector hrefs, test improvements --- .../templates/inspector/routes.sugar.php | 3 +- src/Http/Controller/InspectorController.php | 5 +- src/Http/Middleware/ControllerMiddleware.php | 80 +++++++++++-- .../Middleware/ControllerMiddlewareTest.php | 112 +++++++++++++++++- 4 files changed, 188 insertions(+), 12 deletions(-) diff --git a/resources/templates/inspector/routes.sugar.php b/resources/templates/inspector/routes.sugar.php index b3ce224..12218fe 100644 --- a/resources/templates/inspector/routes.sugar.php +++ b/resources/templates/inspector/routes.sugar.php @@ -1,6 +1,7 @@ $pages + * @var string $basePath Configured site base path (empty string when not set). */ ?> @@ -43,7 +44,7 @@ - + urlPath ?> diff --git a/src/Http/Controller/InspectorController.php b/src/Http/Controller/InspectorController.php index f437009..0961f15 100644 --- a/src/Http/Controller/InspectorController.php +++ b/src/Http/Controller/InspectorController.php @@ -52,6 +52,9 @@ public function routes(): array $this->config->contentTypes, ); - return ['pages' => $pages]; + return [ + 'pages' => $pages, + 'basePath' => $this->config->site->basePath ?? '', + ]; } } diff --git a/src/Http/Middleware/ControllerMiddleware.php b/src/Http/Middleware/ControllerMiddleware.php index 6f4cc27..5c4c141 100644 --- a/src/Http/Middleware/ControllerMiddleware.php +++ b/src/Http/Middleware/ControllerMiddleware.php @@ -74,11 +74,14 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface { $this->discoverOnce(); - $request = $request->withUri( - $request->getUri()->withPath($this->stripBasePathFromRequestPath($request->getUri()->getPath())), - ); + // Use a stripped copy for route matching so the original request (with any + // basePath prefix intact) is forwarded unchanged when no route matches. + // Downstream handlers such as DevPageRequestHandler rely on the original + // path for canonical redirects and Location headers. + $strippedPath = $this->stripBasePathFromRequestPath($request->getUri()->getPath()); + $routingRequest = $request->withUri($request->getUri()->withPath($strippedPath)); - $match = $this->router->match($request); + $match = $this->router->match($routingRequest); if (!$match instanceof MatchedRoute) { return $handler->handle($request); } @@ -87,7 +90,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $controller = $this->container->get($match->controllerClass); $reflectionMethod = new ReflectionMethod($controller, $match->actionMethod); - $args = $this->resolveArguments($reflectionMethod, $request, $match->params); + $args = $this->resolveArguments($reflectionMethod, $routingRequest, $match->params); $result = $reflectionMethod->invokeArgs($controller, $args); @@ -165,9 +168,20 @@ protected function resolveArguments( continue; } - // 2. Path parameter by name. + // 2. Path parameter by name — coerced to the declared builtin type when possible. if (array_key_exists($name, $params)) { - $args[] = $params[$name]; + $rawValue = $params[$name]; + + if ($type instanceof ReflectionNamedType && $type->isBuiltin()) { + $rawValue = $this->coercePathParam( + $rawValue, + $type->getName(), + $name, + $method, + ); + } + + $args[] = $rawValue; continue; } @@ -193,4 +207,56 @@ protected function resolveArguments( return $args; } + + /** + * Coerce a raw path-parameter string to the declared PHP builtin type. + * + * Handles `int`, `float`, and `bool`; passes all other types through as-is. + * + * @param string $raw Raw string extracted from the URL path. + * @param string $typeName PHP builtin type name (e.g. 'int', 'float', 'bool', 'string'). + * @param string $paramName Parameter name (used in error messages). + * @param \ReflectionMethod $method Reflected action method (used in error messages). + * @return string|float|int|bool Coerced value. + * @throws \RuntimeException When the value cannot be converted to the required type. + */ + private function coercePathParam( + string $raw, + string $typeName, + string $paramName, + ReflectionMethod $method, + ): int|float|bool|string { + return match ($typeName) { + 'int' => is_numeric($raw) && !str_contains($raw, '.') + ? (int)$raw + : throw new RuntimeException(sprintf( + 'Path parameter "$%s" for "%s::%s" expects int, got "%s".', + $paramName, + $method->getDeclaringClass()->getName(), + $method->getName(), + $raw, + )), + 'float' => is_numeric($raw) + ? (float)$raw + : throw new RuntimeException(sprintf( + 'Path parameter "$%s" for "%s::%s" expects float, got "%s".', + $paramName, + $method->getDeclaringClass()->getName(), + $method->getName(), + $raw, + )), + 'bool' => match (strtolower($raw)) { + '1', 'true', 'yes', 'on' => true, + '0', 'false', 'no', 'off', '' => false, + default => throw new RuntimeException(sprintf( + 'Path parameter "$%s" for "%s::%s" expects bool, got "%s".', + $paramName, + $method->getDeclaringClass()->getName(), + $method->getName(), + $raw, + )), + }, + default => $raw, + }; + } } diff --git a/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php b/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php index 47bca77..c998d85 100644 --- a/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php +++ b/tests/Unit/Http/Middleware/ControllerMiddlewareTest.php @@ -16,6 +16,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use ReflectionProperty; /** * Tests for ControllerMiddleware routing and injection behavior. @@ -174,8 +175,9 @@ public function check(ServerRequestInterface $request): ResponseInterface { /** * Ensure controller discovery only runs once even across multiple requests. * - * A temp directory is created with one controller file. Both requests match - * the same route, confirming discovery persisted after the first request. + * After the first request triggers discovery, the route table is inspected + * via reflection before and after a second request to confirm the entries + * were not duplicated or cleared. */ public function testDiscoveryRunsOnlyOnce(): void { @@ -213,9 +215,113 @@ public function index(): ResponseInterface { $response1 = $middleware->process($request, $this->fallbackHandler()); $this->assertSame(200, $response1->getStatusCode()); - // Second request — discovery must NOT clear and re-run (route still matched). + // Capture route table size immediately after discovery. + $routesProp = new ReflectionProperty($router, 'routes'); + /** @var array $routesAfterFirst */ + $routesAfterFirst = $routesProp->getValue($router); + $routeCountAfterFirstRequest = count($routesAfterFirst); + $this->assertGreaterThan(0, $routeCountAfterFirstRequest, 'Routes must be registered after first request.'); + + // Second request — discovery must NOT re-run; the route table must be unchanged. $response2 = $middleware->process($request, $this->fallbackHandler()); $this->assertSame(200, $response2->getStatusCode()); + /** @var array $routesAfterSecond */ + $routesAfterSecond = $routesProp->getValue($router); + $this->assertCount( + $routeCountAfterFirstRequest, + $routesAfterSecond, + 'Route count must not change after the second request; discoverOnce must not have re-run.', + ); + } + + /** + * Ensure the original (basePath-prefixed) request is forwarded unchanged when no route matches. + * + * Downstream handlers such as DevPageRequestHandler rely on the full original + * path (including any basePath prefix) for canonical redirects and Location headers. + */ + public function testProcessForwardsOriginalRequestPathOnMiss(): void + { + $projectRoot = $this->createProjectRoot(); + file_put_contents($projectRoot . '/glaze.neon', "site:\n basePath: /app\n"); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $router = new ControllerRouter(); + $middleware = new ControllerMiddleware( + $router, + $this->makeViewRenderer($config), + $this->container(), + $config, + $this->createTempDirectory(), + ); + + $capturedPath = null; + $capturingHandler = new class ($capturedPath) implements RequestHandlerInterface { + public function __construct(public ?string &$path) + { + } + + /** + * @inheritDoc + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->path = $request->getUri()->getPath(); + + return (new Response(['charset' => 'UTF-8']))->withStatus(404)->withStringBody('miss'); + } + }; + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/app/about/'); + $middleware->process($request, $capturingHandler); + + $this->assertSame('/app/about/', $capturedPath, 'The original basePath-prefixed path must be forwarded unchanged.'); + } + + /** + * Ensure path parameters are coerced to declared scalar types (int, float, bool). + */ + public function testProcessCoercesScalarPathParams(): void + { + $projectRoot = $this->createProjectRoot(); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + + $controllersDir = $projectRoot . '/controllers'; + mkdir($controllersDir, 0755, true); + file_put_contents($controllersDir . '/TypedController.php', <<<'PHP' + 'UTF-8'])) + ->withStatus(200) + ->withStringBody(json_encode(['id' => $id, 'score' => $score, 'active' => $active])); + } + } + PHP); + + $router = new ControllerRouter(); + $middleware = new ControllerMiddleware( + $router, + $this->makeViewRenderer($config), + $this->container(), + $config, + $controllersDir, + ); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/items/42/3.14/true'); + $response = $middleware->process($request, $this->fallbackHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $decoded = json_decode((string)$response->getBody(), true); + $this->assertIsArray($decoded); + $this->assertSame(42, $decoded['id']); + $this->assertEqualsWithDelta(3.14, $decoded['score'], PHP_FLOAT_EPSILON); + $this->assertTrue($decoded['active']); } /** From b8d58cf41ef332e67d851f984656d787a0dfb08a Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 14 Mar 2026 19:10:25 +0100 Subject: [PATCH 4/9] fix: address second round of review feedback - Pass original request (not basePath-stripped copy) to action resolveArguments so controller actions see the real incoming URI path - Switch InspectorController to LocalizedContentDiscovery so i18n-enabled projects see all language-prefixed pages in the routes inspector - Prefix CSS stylesheet href and nav link with $basePath in inspector template so subpath deployments resolve inspector assets/navigation correctly - HTML-escape all ContentPage values (urlPath, title, type) in template to prevent XSS from project frontmatter content - Update CoreAssetMiddleware docblock to accurately describe PHP-emitted basePath prefix (not Sugar static-attribute rewriting) --- resources/templates/inspector/routes.sugar.php | 12 ++++++------ src/Http/Controller/InspectorController.php | 15 +++++++-------- src/Http/Middleware/ControllerMiddleware.php | 2 +- src/Http/Middleware/CoreAssetMiddleware.php | 9 +++++---- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/resources/templates/inspector/routes.sugar.php b/resources/templates/inspector/routes.sugar.php index 12218fe..5758295 100644 --- a/resources/templates/inspector/routes.sugar.php +++ b/resources/templates/inspector/routes.sugar.php @@ -10,7 +10,7 @@ Glaze Inspector - Routes - +
@@ -18,7 +18,7 @@
@@ -44,13 +44,13 @@ - - urlPath ?> + + urlPath, ENT_QUOTES, 'UTF-8') ?> - title ?> + title, ENT_QUOTES, 'UTF-8') ?> - type ?> + type, ENT_QUOTES, 'UTF-8') ?> default diff --git a/src/Http/Controller/InspectorController.php b/src/Http/Controller/InspectorController.php index 0961f15..f7d3e70 100644 --- a/src/Http/Controller/InspectorController.php +++ b/src/Http/Controller/InspectorController.php @@ -4,7 +4,7 @@ namespace Glaze\Http\Controller; use Glaze\Config\BuildConfig; -use Glaze\Content\ContentDiscoveryService; +use Glaze\Content\LocalizedContentDiscovery; use Glaze\Http\Attribute\Route; use Glaze\Http\Attribute\RoutePrefix; @@ -28,11 +28,11 @@ final class InspectorController /** * Constructor. * - * @param \Glaze\Content\ContentDiscoveryService $discoveryService Content discovery service. + * @param \Glaze\Content\LocalizedContentDiscovery $discoveryService i18n-aware content discovery service. * @param \Glaze\Config\BuildConfig $config Build configuration. */ public function __construct( - private ContentDiscoveryService $discoveryService, + private LocalizedContentDiscovery $discoveryService, private BuildConfig $config, ) { } @@ -40,17 +40,16 @@ public function __construct( /** * List all discovered content pages and their URL paths. * + * Uses the i18n-aware discovery so that language-prefixed pages are always + * included, matching the page list shown at runtime. + * * @return array Template variables with key `pages` containing * all discovered {@see \Glaze\Content\ContentPage} instances. */ #[Route('/routes')] public function routes(): array { - $pages = $this->discoveryService->discover( - $this->config->contentPath(), - $this->config->taxonomies, - $this->config->contentTypes, - ); + $pages = $this->discoveryService->discover($this->config); return [ 'pages' => $pages, diff --git a/src/Http/Middleware/ControllerMiddleware.php b/src/Http/Middleware/ControllerMiddleware.php index 5c4c141..c95677c 100644 --- a/src/Http/Middleware/ControllerMiddleware.php +++ b/src/Http/Middleware/ControllerMiddleware.php @@ -90,7 +90,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $controller = $this->container->get($match->controllerClass); $reflectionMethod = new ReflectionMethod($controller, $match->actionMethod); - $args = $this->resolveArguments($reflectionMethod, $routingRequest, $match->params); + $args = $this->resolveArguments($reflectionMethod, $request, $match->params); $result = $reflectionMethod->invokeArgs($controller, $args); diff --git a/src/Http/Middleware/CoreAssetMiddleware.php b/src/Http/Middleware/CoreAssetMiddleware.php index f60ac50..e0d34bc 100644 --- a/src/Http/Middleware/CoreAssetMiddleware.php +++ b/src/Http/Middleware/CoreAssetMiddleware.php @@ -19,10 +19,11 @@ * that glaze's dev-UI (inspector, routes viewer, etc.) works without any * project-level configuration. * - * When a project configures a basePath (e.g. `/myapp`), Sugar templates emit - * asset hrefs prefixed with that basePath (e.g. `/myapp/_glaze/assets/...`). - * This middleware strips the project basePath before matching so that - * `/_glaze/assets/` is always resolved correctly regardless of deployment prefix. + * Inspector templates emit asset hrefs with the basePath prepended via PHP + * (e.g. `/_glaze/assets/css/dev.css`), so the browser always + * requests the full `/{basePath}/_glaze/assets/...` URL. This middleware strips + * the configured basePath before matching so both bare and prefixed forms are + * resolved correctly: * * Example: * GET /_glaze/assets/css/dev.css → {package}/resources/assets/css/dev.css From 5052b1eddf3f6d2f2b0e6327d2b35a92d9afa551 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Mon, 16 Mar 2026 19:44:00 +0100 Subject: [PATCH 5/9] route update --- src/Http/Controller/InspectorController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Http/Controller/InspectorController.php b/src/Http/Controller/InspectorController.php index f7d3e70..b3e830f 100644 --- a/src/Http/Controller/InspectorController.php +++ b/src/Http/Controller/InspectorController.php @@ -46,6 +46,7 @@ public function __construct( * @return array Template variables with key `pages` containing * all discovered {@see \Glaze\Content\ContentPage} instances. */ + #[Route('/')] #[Route('/routes')] public function routes(): array { From 5b70ead7abe6e44f299b93a65f925b7fc9ac9601 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Mon, 16 Mar 2026 19:47:48 +0100 Subject: [PATCH 6/9] clean up static mode --- bin/dev-router.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bin/dev-router.php b/bin/dev-router.php index c1f1c8c..c81e1df 100644 --- a/bin/dev-router.php +++ b/bin/dev-router.php @@ -41,8 +41,6 @@ Configure::write('build.drafts', true); } -/** @var \Glaze\Http\Middleware\PublicAssetMiddleware $publicAssetMiddleware */ -$publicAssetMiddleware = $container->get(PublicAssetMiddleware::class); /** @var \Glaze\Http\Middleware\StaticAssetMiddleware $staticAssetMiddleware */ $staticAssetMiddleware = $container->get(StaticAssetMiddleware::class); /** @var \Glaze\Http\Middleware\ContentAssetMiddleware $contentAssetMiddleware */ @@ -78,7 +76,13 @@ $queue = new MiddlewareQueue(); $queue->add(new ErrorHandlingMiddleware(true)); -$queue->add($publicAssetMiddleware); + +if ($staticMode) { + /** @var \Glaze\Http\Middleware\PublicAssetMiddleware $publicAssetMiddleware */ + $publicAssetMiddleware = $container->get(PublicAssetMiddleware::class); + $queue->add($publicAssetMiddleware); +} + $queue->add($staticAssetMiddleware); $queue->add($contentAssetMiddleware); From 78ef3ceaf392fa620d2b8f9b2b80cf766ae4970c Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Mon, 16 Mar 2026 20:00:44 +0100 Subject: [PATCH 7/9] register middlewares from application --- bin/dev-router.php | 32 +----------- resources/{ => backend}/assets/css/dev.css | 0 resources/{ => backend}/package.json | 1 - .../templates/inspector/routes.sugar.php | 0 resources/{ => backend}/vite.config.js | 0 src/Application.php | 44 ++++++++++++++++ src/Http/Middleware/CoreAssetMiddleware.php | 8 +-- src/Http/Routing/ControllerViewRenderer.php | 8 +-- tests/Unit/ApplicationTest.php | 52 +++++++++++++++++++ .../Middleware/CoreAssetMiddlewareTest.php | 2 +- 10 files changed, 106 insertions(+), 41 deletions(-) rename resources/{ => backend}/assets/css/dev.css (100%) rename resources/{ => backend}/package.json (90%) rename resources/{ => backend}/templates/inspector/routes.sugar.php (100%) rename resources/{ => backend}/vite.config.js (100%) diff --git a/bin/dev-router.php b/bin/dev-router.php index 783150a..4f6b1b7 100644 --- a/bin/dev-router.php +++ b/bin/dev-router.php @@ -9,12 +9,6 @@ use Glaze\Application; use Glaze\Config\ProjectConfigurationReader; use Glaze\Http\DevPageRequestHandler; -use Glaze\Http\Middleware\ContentAssetMiddleware; -use Glaze\Http\Middleware\ControllerMiddleware; -use Glaze\Http\Middleware\CoreAssetMiddleware; -use Glaze\Http\Middleware\ErrorHandlingMiddleware; -use Glaze\Http\Middleware\PublicAssetMiddleware; -use Glaze\Http\Middleware\StaticAssetMiddleware; use Glaze\Http\StaticPageRequestHandler; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -43,11 +37,6 @@ Configure::write('build.drafts', true); } -/** @var \Glaze\Http\Middleware\StaticAssetMiddleware $staticAssetMiddleware */ -$staticAssetMiddleware = $container->get(StaticAssetMiddleware::class); -/** @var \Glaze\Http\Middleware\ContentAssetMiddleware $contentAssetMiddleware */ -$contentAssetMiddleware = $container->get(ContentAssetMiddleware::class); - if ($staticMode) { /** @var \Glaze\Http\StaticPageRequestHandler $fallbackHandler */ $fallbackHandler = $container->get(StaticPageRequestHandler::class); @@ -76,26 +65,7 @@ $request = $request->withQueryParams($queryParams); } -$queue = new MiddlewareQueue(); -$queue->add(new ErrorHandlingMiddleware(true)); - -if ($staticMode) { - /** @var \Glaze\Http\Middleware\PublicAssetMiddleware $publicAssetMiddleware */ - $publicAssetMiddleware = $container->get(PublicAssetMiddleware::class); - $queue->add($publicAssetMiddleware); -} - -$queue->add($staticAssetMiddleware); -$queue->add($contentAssetMiddleware); - -if (!$staticMode) { - /** @var \Glaze\Http\Middleware\CoreAssetMiddleware $coreAssetMiddleware */ - $coreAssetMiddleware = $container->get(CoreAssetMiddleware::class); - /** @var \Glaze\Http\Middleware\ControllerMiddleware $controllerMiddleware */ - $controllerMiddleware = $container->get(ControllerMiddleware::class); - $queue->add($coreAssetMiddleware); - $queue->add($controllerMiddleware); -} +$queue = $application->middleware(new MiddlewareQueue(), $staticMode); $response = (new Runner())->run($queue, $request, $fallbackHandler); diff --git a/resources/assets/css/dev.css b/resources/backend/assets/css/dev.css similarity index 100% rename from resources/assets/css/dev.css rename to resources/backend/assets/css/dev.css diff --git a/resources/package.json b/resources/backend/package.json similarity index 90% rename from resources/package.json rename to resources/backend/package.json index 573c5a4..5727ddd 100644 --- a/resources/package.json +++ b/resources/backend/package.json @@ -7,7 +7,6 @@ "build": "vite build" }, "devDependencies": { - "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", "alpinejs": "^3.14.9", "daisyui": "^5.0.0", diff --git a/resources/templates/inspector/routes.sugar.php b/resources/backend/templates/inspector/routes.sugar.php similarity index 100% rename from resources/templates/inspector/routes.sugar.php rename to resources/backend/templates/inspector/routes.sugar.php diff --git a/resources/vite.config.js b/resources/backend/vite.config.js similarity index 100% rename from resources/vite.config.js rename to resources/backend/vite.config.js diff --git a/src/Application.php b/src/Application.php index 58a434e..d2834cc 100644 --- a/src/Application.php +++ b/src/Application.php @@ -9,6 +9,7 @@ use Cake\Core\Container; use Cake\Core\ContainerApplicationInterface; use Cake\Core\ContainerInterface; +use Cake\Http\MiddlewareQueue; use Glaze\Command\BuildCommand; use Glaze\Command\CacheCommand; use Glaze\Command\HelpCommand; @@ -18,7 +19,12 @@ use Glaze\Command\ServeCommand; use Glaze\Config\BuildConfig; use Glaze\Config\NeonConfigEngine; +use Glaze\Http\Middleware\ContentAssetMiddleware; use Glaze\Http\Middleware\ControllerMiddleware; +use Glaze\Http\Middleware\CoreAssetMiddleware; +use Glaze\Http\Middleware\ErrorHandlingMiddleware; +use Glaze\Http\Middleware\PublicAssetMiddleware; +use Glaze\Http\Middleware\StaticAssetMiddleware; use Glaze\Http\Routing\ControllerRouter; use Glaze\Http\Routing\ControllerViewRenderer; use Glaze\Image\GlideImageTransformer; @@ -117,6 +123,44 @@ static function () use ($container): ControllerMiddleware { ); } + /** + * Register HTTP middleware stack for dev-router execution. + * + * @param \Cake\Http\MiddlewareQueue $queue Middleware queue to populate. + * @param bool $staticMode Whether static mode is active. + * @return \Cake\Http\MiddlewareQueue Populated middleware queue. + */ + public function middleware(MiddlewareQueue $queue, bool $staticMode): MiddlewareQueue + { + $container = $this->getContainer(); + + $queue->add(new ErrorHandlingMiddleware(true)); + + if ($staticMode) { + /** @var \Glaze\Http\Middleware\PublicAssetMiddleware $publicAssetMiddleware */ + $publicAssetMiddleware = $container->get(PublicAssetMiddleware::class); + $queue->add($publicAssetMiddleware); + } + + /** @var \Glaze\Http\Middleware\StaticAssetMiddleware $staticAssetMiddleware */ + $staticAssetMiddleware = $container->get(StaticAssetMiddleware::class); + /** @var \Glaze\Http\Middleware\ContentAssetMiddleware $contentAssetMiddleware */ + $contentAssetMiddleware = $container->get(ContentAssetMiddleware::class); + $queue->add($staticAssetMiddleware); + $queue->add($contentAssetMiddleware); + + if (!$staticMode) { + /** @var \Glaze\Http\Middleware\CoreAssetMiddleware $coreAssetMiddleware */ + $coreAssetMiddleware = $container->get(CoreAssetMiddleware::class); + /** @var \Glaze\Http\Middleware\ControllerMiddleware $controllerMiddleware */ + $controllerMiddleware = $container->get(ControllerMiddleware::class); + $queue->add($coreAssetMiddleware); + $queue->add($controllerMiddleware); + } + + return $queue; + } + /** * Build and return the application dependency injection container. */ diff --git a/src/Http/Middleware/CoreAssetMiddleware.php b/src/Http/Middleware/CoreAssetMiddleware.php index e0d34bc..0f76b18 100644 --- a/src/Http/Middleware/CoreAssetMiddleware.php +++ b/src/Http/Middleware/CoreAssetMiddleware.php @@ -15,7 +15,7 @@ /** * Serves package-internal assets from under the /_glaze/assets/ URL prefix. * - * Assets are resolved from the package's own resources/assets/ directory so + * Assets are resolved from the package's own resources/backend/assets/ directory so * that glaze's dev-UI (inspector, routes viewer, etc.) works without any * project-level configuration. * @@ -26,7 +26,7 @@ * resolved correctly: * * Example: - * GET /_glaze/assets/css/dev.css → {package}/resources/assets/css/dev.css + * GET /_glaze/assets/css/dev.css → {package}/resources/backend/assets/css/dev.css * GET /myapp/_glaze/assets/css/dev.css → same (basePath stripped first) */ final class CoreAssetMiddleware implements MiddlewareInterface @@ -39,7 +39,7 @@ final class CoreAssetMiddleware implements MiddlewareInterface private const URL_PREFIX = '/_glaze/assets'; /** - * Absolute path to the package's resources/assets/ directory. + * Absolute path to the package's resources/backend/assets/ directory. */ private string $assetsRootPath; @@ -53,7 +53,7 @@ public function __construct( protected BuildConfig $config, private AssetResponder $assetResponder, ) { - $this->assetsRootPath = dirname(__DIR__, 3) . '/resources/assets'; + $this->assetsRootPath = dirname(__DIR__, 3) . '/resources/backend/assets'; } /** diff --git a/src/Http/Routing/ControllerViewRenderer.php b/src/Http/Routing/ControllerViewRenderer.php index cad77e6..7aca114 100644 --- a/src/Http/Routing/ControllerViewRenderer.php +++ b/src/Http/Routing/ControllerViewRenderer.php @@ -14,8 +14,8 @@ /** * Renders core controller action views using the Sugar template engine. * - * Templates are resolved from the package's own resources/templates/ directory: - * {package}/resources/templates/{controller}/{action}.sugar.php + * Templates are resolved from the package's own resources/backend/templates/ directory: + * {package}/resources/backend/templates/{controller}/{action}.sugar.php * * An alternative template directory may be injected via the constructor for * testing or future extension. The renderer always runs in debug mode so that @@ -41,14 +41,14 @@ final class ControllerViewRenderer * @param \Glaze\Config\BuildConfig $config Build configuration (cache path + site config). * @param \Glaze\Support\ResourcePathRewriter $resourcePathRewriter Shared path rewriter. * @param string|null $templateDirectory Override template directory for testing. Defaults to - * the package's resources/templates/ when null. + * the package's resources/backend/templates/ when null. */ public function __construct( private BuildConfig $config, private ResourcePathRewriter $resourcePathRewriter, ?string $templateDirectory = null, ) { - $this->templateDirectory = $templateDirectory ?? dirname(__DIR__, 3) . '/resources/templates'; + $this->templateDirectory = $templateDirectory ?? dirname(__DIR__, 3) . '/resources/backend/templates'; } /** diff --git a/tests/Unit/ApplicationTest.php b/tests/Unit/ApplicationTest.php index 38d26f9..4d6215d 100644 --- a/tests/Unit/ApplicationTest.php +++ b/tests/Unit/ApplicationTest.php @@ -5,9 +5,16 @@ use Cake\Console\CommandCollection; use Cake\Core\Configure; +use Cake\Http\MiddlewareQueue; use Glaze\Application; use Glaze\Config\BuildConfig; use Glaze\Config\ProjectConfigurationReader; +use Glaze\Http\Middleware\ContentAssetMiddleware; +use Glaze\Http\Middleware\ControllerMiddleware; +use Glaze\Http\Middleware\CoreAssetMiddleware; +use Glaze\Http\Middleware\ErrorHandlingMiddleware; +use Glaze\Http\Middleware\PublicAssetMiddleware; +use Glaze\Http\Middleware\StaticAssetMiddleware; use Glaze\Image\ImageTransformerInterface; use PHPUnit\Framework\TestCase; @@ -120,4 +127,49 @@ public function testSharedBuildConfigReturnsSameInstance(): void $this->assertSame($first, $second); } + + /** + * Ensure middleware() registers the expected stack in live mode. + */ + public function testMiddlewareRegistersExpectedLiveStack(): void + { + $application = new Application(); + $application->bootstrap(); + + (new ProjectConfigurationReader())->read('/tmp/glaze-project'); + Configure::write('projectRoot', '/tmp/glaze-project'); + + $queue = $application->middleware(new MiddlewareQueue(), false); + $middlewares = iterator_to_array($queue); + + $this->assertSame([ + ErrorHandlingMiddleware::class, + StaticAssetMiddleware::class, + ContentAssetMiddleware::class, + CoreAssetMiddleware::class, + ControllerMiddleware::class, + ], array_map(static fn(object $middleware): string => $middleware::class, $middlewares)); + } + + /** + * Ensure middleware() registers the expected stack in static mode. + */ + public function testMiddlewareRegistersExpectedStaticStack(): void + { + $application = new Application(); + $application->bootstrap(); + + (new ProjectConfigurationReader())->read('/tmp/glaze-project'); + Configure::write('projectRoot', '/tmp/glaze-project'); + + $queue = $application->middleware(new MiddlewareQueue(), true); + $middlewares = iterator_to_array($queue); + + $this->assertSame([ + ErrorHandlingMiddleware::class, + PublicAssetMiddleware::class, + StaticAssetMiddleware::class, + ContentAssetMiddleware::class, + ], array_map(static fn(object $middleware): string => $middleware::class, $middlewares)); + } } diff --git a/tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php b/tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php index 29d423d..167f3d8 100644 --- a/tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php +++ b/tests/Unit/Http/Middleware/CoreAssetMiddlewareTest.php @@ -116,7 +116,7 @@ public function testProcessServesAssetWithBasePathSetButNoPrefix(): void /** * Build a CoreAssetMiddleware with the given assets directory root overridden via reflection. * - * @param string $assetsDir Temporary assets directory acting as resources/assets/. + * @param string $assetsDir Temporary assets directory acting as resources/backend/assets/. * @param string|null $basePath Optional site basePath (simulates glaze.neon site.basePath). */ protected function makeMiddleware(string $assetsDir, ?string $basePath = null): CoreAssetMiddleware From 3459dff9659949b59a33ed60f3d03a8a65adc761 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Mon, 16 Mar 2026 20:59:37 +0100 Subject: [PATCH 8/9] more backend vite clenaup work --- bin/dev-router.php | 11 +- resources/backend/.gitignore | 1 + resources/backend/package-lock.json | 1861 +++++++++++++++++ .../templates/inspector/routes.sugar.php | 4 +- resources/backend/vite.config.js | 2 +- src/Application.php | 26 + src/Config/TemplateViteOptions.php | 45 + src/Http/Routing/ControllerViewRenderer.php | 34 +- src/Render/SugarPageRenderer.php | 23 +- src/Render/SugarPageRendererFactory.php | 6 +- tests/Unit/ApplicationTest.php | 34 + tests/Unit/Config/TemplateViteOptionsTest.php | 110 + .../Routing/ControllerViewRendererTest.php | 42 + tests/Unit/Render/SugarPageRendererTest.php | 94 + 14 files changed, 2251 insertions(+), 42 deletions(-) create mode 100644 resources/backend/.gitignore create mode 100644 resources/backend/package-lock.json diff --git a/bin/dev-router.php b/bin/dev-router.php index 4f6b1b7..9289450 100644 --- a/bin/dev-router.php +++ b/bin/dev-router.php @@ -8,8 +8,6 @@ use Cake\Http\ServerRequestFactory; use Glaze\Application; use Glaze\Config\ProjectConfigurationReader; -use Glaze\Http\DevPageRequestHandler; -use Glaze\Http\StaticPageRequestHandler; require dirname(__DIR__) . '/vendor/autoload.php'; @@ -29,7 +27,6 @@ $application = new Application(); $application->bootstrap(); -$container = $application->getContainer(); (new ProjectConfigurationReader())->read($projectRoot); Configure::write('projectRoot', $projectRoot); @@ -37,13 +34,7 @@ Configure::write('build.drafts', true); } -if ($staticMode) { - /** @var \Glaze\Http\StaticPageRequestHandler $fallbackHandler */ - $fallbackHandler = $container->get(StaticPageRequestHandler::class); -} else { - /** @var \Glaze\Http\DevPageRequestHandler $fallbackHandler */ - $fallbackHandler = $container->get(DevPageRequestHandler::class); -} +$fallbackHandler = $application->fallbackHandler($staticMode); $requestMethod = $_SERVER['REQUEST_METHOD'] ?? null; if (!is_string($requestMethod) || $requestMethod === '') { diff --git a/resources/backend/.gitignore b/resources/backend/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/resources/backend/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/resources/backend/package-lock.json b/resources/backend/package-lock.json new file mode 100644 index 0000000..365e1ad --- /dev/null +++ b/resources/backend/package-lock.json @@ -0,0 +1,1861 @@ +{ + "name": "glaze-core-assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "glaze-core-assets", + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.14.9", + "daisyui": "^5.0.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.15.8", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.8.tgz", + "integrity": "sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/resources/backend/templates/inspector/routes.sugar.php b/resources/backend/templates/inspector/routes.sugar.php index 5758295..aa8a9e3 100644 --- a/resources/backend/templates/inspector/routes.sugar.php +++ b/resources/backend/templates/inspector/routes.sugar.php @@ -10,7 +10,9 @@ Glaze Inspector - Routes - +
diff --git a/resources/backend/vite.config.js b/resources/backend/vite.config.js index 9cf8a13..470a3f7 100644 --- a/resources/backend/vite.config.js +++ b/resources/backend/vite.config.js @@ -9,7 +9,7 @@ export default defineConfig({ cors: true, }, build: { - outDir: 'dist', + outDir: 'assets/dist', manifest: true, rollupOptions: { input: { diff --git a/src/Application.php b/src/Application.php index d2834cc..98c7419 100644 --- a/src/Application.php +++ b/src/Application.php @@ -19,6 +19,7 @@ use Glaze\Command\ServeCommand; use Glaze\Config\BuildConfig; use Glaze\Config\NeonConfigEngine; +use Glaze\Http\DevPageRequestHandler; use Glaze\Http\Middleware\ContentAssetMiddleware; use Glaze\Http\Middleware\ControllerMiddleware; use Glaze\Http\Middleware\CoreAssetMiddleware; @@ -27,12 +28,14 @@ use Glaze\Http\Middleware\StaticAssetMiddleware; use Glaze\Http\Routing\ControllerRouter; use Glaze\Http\Routing\ControllerViewRenderer; +use Glaze\Http\StaticPageRequestHandler; use Glaze\Image\GlideImageTransformer; use Glaze\Image\ImagePresetResolver; use Glaze\Image\ImageTransformerInterface; use Glaze\Scaffold\ScaffoldRegistry; use Glaze\Scaffold\ScaffoldSchemaLoader; use League\Container\ReflectionContainer; +use Psr\Http\Server\RequestHandlerInterface; /** * Console application entrypoint for Glaze commands. @@ -161,6 +164,29 @@ public function middleware(MiddlewareQueue $queue, bool $staticMode): Middleware return $queue; } + /** + * Resolve the request fallback handler for the selected router mode. + * + * @param bool $staticMode Whether static mode is active. + * @return \Psr\Http\Server\RequestHandlerInterface Fallback request handler. + */ + public function fallbackHandler(bool $staticMode): RequestHandlerInterface + { + $container = $this->getContainer(); + + if ($staticMode) { + /** @var \Glaze\Http\StaticPageRequestHandler $fallbackHandler */ + $fallbackHandler = $container->get(StaticPageRequestHandler::class); + + return $fallbackHandler; + } + + /** @var \Glaze\Http\DevPageRequestHandler $fallbackHandler */ + $fallbackHandler = $container->get(DevPageRequestHandler::class); + + return $fallbackHandler; + } + /** * Build and return the application dependency injection container. */ diff --git a/src/Config/TemplateViteOptions.php b/src/Config/TemplateViteOptions.php index 82d4629..2076533 100644 --- a/src/Config/TemplateViteOptions.php +++ b/src/Config/TemplateViteOptions.php @@ -33,6 +33,9 @@ * @param string $devServerUrl Dev server origin URL. * @param bool $injectClient Whether `@vite/client` is auto-injected in dev mode. * @param string|null $defaultEntry Default entry used when directive is boolean. + * @param string $mode Vite resolver mode: `auto` delegates to the Sugar engine debug flag, `dev` always uses + * the dev server, `prod` always uses the manifest. When `auto`, the mode is determined by + * whether the parent `SugarPageRenderer` was created with `debug: true`. */ public function __construct( public bool $buildEnabled = false, @@ -42,6 +45,7 @@ public function __construct( public string $devServerUrl = 'http://127.0.0.1:5173', public bool $injectClient = true, public ?string $defaultEntry = null, + public string $mode = 'auto', ) { } @@ -89,6 +93,47 @@ public static function fromProjectConfig(array $buildVite, array $devVite, strin devServerUrl: $devServerUrl, injectClient: is_bool($devVite['injectClient'] ?? null) ? $devVite['injectClient'] : true, defaultEntry: $defaultEntry, + mode: is_string($buildVite['mode'] ?? null) && in_array($buildVite['mode'], ['auto', 'dev', 'prod'], true) + ? $buildVite['mode'] + : 'auto', + ); + } + + /** + * Return a new instance with runtime environment overrides applied. + * + * Reads `GLAZE_VITE_ENABLED` and `GLAZE_VITE_URL` from the process environment and + * overlays them on the current options. This is the only place in the codebase that + * reads these variables; call it at the factory/construction layer, not inside renderers. + * + * - `GLAZE_VITE_ENABLED=1` forces devEnabled on; `0` forces it off. + * - `GLAZE_VITE_URL` replaces devServerUrl when non-empty. + */ + public function applyEnvironmentOverrides(): self + { + $devEnabled = $this->devEnabled; + $enabledOverride = getenv('GLAZE_VITE_ENABLED'); + if ($enabledOverride === '1') { + $devEnabled = true; + } elseif ($enabledOverride === '0') { + $devEnabled = false; + } + + $devServerUrl = $this->devServerUrl; + $runtimeUrl = getenv('GLAZE_VITE_URL'); + if (is_string($runtimeUrl) && $runtimeUrl !== '') { + $devServerUrl = $runtimeUrl; + } + + return new self( + buildEnabled: $this->buildEnabled, + devEnabled: $devEnabled, + assetBaseUrl: $this->assetBaseUrl, + manifestPath: $this->manifestPath, + devServerUrl: $devServerUrl, + injectClient: $this->injectClient, + defaultEntry: $this->defaultEntry, + mode: $this->mode, ); } diff --git a/src/Http/Routing/ControllerViewRenderer.php b/src/Http/Routing/ControllerViewRenderer.php index 7aca114..a0d69cc 100644 --- a/src/Http/Routing/ControllerViewRenderer.php +++ b/src/Http/Routing/ControllerViewRenderer.php @@ -17,13 +17,9 @@ * Templates are resolved from the package's own resources/backend/templates/ directory: * {package}/resources/backend/templates/{controller}/{action}.sugar.php * - * An alternative template directory may be injected via the constructor for - * testing or future extension. The renderer always runs in debug mode so that - * template edits during Glaze development are picked up on every request. - * - * Vite integration is intentionally disabled here; core dev-UI templates link - * to /_glaze/assets/ directly. The Vite pipeline (resources/vite.config.js) - * is wired in once the built assets are committed to resources/dist/. + * Vite integration is wired to the package's own resources/backend/vite.config.js build + * (port 5184, outDir assets/dist). A custom TemplateViteOptions instance may be injected + * via the constructor to override the defaults, which is useful in tests. * * Example: * $viewRenderer->render($matchedRoute, ['pages' => $pages]); @@ -35,20 +31,34 @@ final class ControllerViewRenderer */ private string $templateDirectory; + /** + * Vite options used when creating SugarPageRenderer instances. + */ + protected TemplateViteOptions $backendVite; + /** * Constructor. * * @param \Glaze\Config\BuildConfig $config Build configuration (cache path + site config). * @param \Glaze\Support\ResourcePathRewriter $resourcePathRewriter Shared path rewriter. - * @param string|null $templateDirectory Override template directory for testing. Defaults to - * the package's resources/backend/templates/ when null. + * @param string|null $templateDirectory Override template directory. Defaults to the package's + * resources/backend/templates/ when null. */ public function __construct( private BuildConfig $config, private ResourcePathRewriter $resourcePathRewriter, ?string $templateDirectory = null, ) { - $this->templateDirectory = $templateDirectory ?? dirname(__DIR__, 3) . '/resources/backend/templates'; + $packageRoot = dirname(__DIR__, 3); + $this->templateDirectory = $templateDirectory ?? $packageRoot . '/resources/backend/templates'; + $this->backendVite = new TemplateViteOptions( + buildEnabled: true, + devEnabled: true, + assetBaseUrl: '/_glaze/assets/dist/', + manifestPath: $packageRoot . '/resources/backend/assets/dist/.vite/manifest.json', + devServerUrl: 'http://localhost:5174', + mode: 'dev', + ); } /** @@ -63,11 +73,11 @@ public function render(MatchedRoute $route, array $data): ResponseInterface $renderer = new SugarPageRenderer( templatePath: $this->templateDirectory, - cachePath: $this->config->cachePath(CachePath::Sugar), + cachePath: $this->config->cachePath(CachePath::Sugar) . '/backend', template: $templateName, siteConfig: $this->config->site, resourcePathRewriter: $this->resourcePathRewriter, - templateVite: new TemplateViteOptions(), + templateVite: $this->backendVite, debug: true, ); diff --git a/src/Render/SugarPageRenderer.php b/src/Render/SugarPageRenderer.php index 5edb0a6..3d70051 100644 --- a/src/Render/SugarPageRenderer.php +++ b/src/Render/SugarPageRenderer.php @@ -166,6 +166,11 @@ protected function createEngine(?object $templateContext = null): Engine /** * Resolve Sugar Vite extension configuration for current render mode. * + * The `mode` field of {@see TemplateViteOptions} is passed directly to the Sugar Vite extension: + * - `auto` — Sugar uses the engine debug flag to decide (dev when debug, prod otherwise). + * - `dev` — always connects to the Vite dev server regardless of debug state. + * - `prod` — always reads from the manifest regardless of debug state. + * * @return array{mode: string, assetBaseUrl: string, manifestPath: string|null, devServerUrl: string, injectClient: bool, defaultEntry: string|null}|null */ protected function resolveViteConfiguration(): ?array @@ -175,34 +180,18 @@ protected function resolveViteConfiguration(): ?array ? $viteConfiguration->devEnabled : $viteConfiguration->buildEnabled; - if ($this->debug) { - $enabledOverride = getenv('GLAZE_VITE_ENABLED'); - if ($enabledOverride === '1') { - $isEnabled = true; - } elseif ($enabledOverride === '0') { - $isEnabled = false; - } - } - if (!$isEnabled) { return null; } $devServerUrl = $viteConfiguration->devServerUrl; - if ($this->debug) { - $runtimeViteUrl = getenv('GLAZE_VITE_URL'); - if (is_string($runtimeViteUrl) && $runtimeViteUrl !== '') { - $devServerUrl = $runtimeViteUrl; - } - } - $assetBaseUrl = $viteConfiguration->assetBaseUrl; if (!$this->resourcePathRewriter->isExternalResourcePath($assetBaseUrl)) { $assetBaseUrl = $this->resourcePathRewriter->applyBasePathToPath($assetBaseUrl, $this->siteConfig); } return [ - 'mode' => $this->debug ? 'dev' : 'prod', + 'mode' => $viteConfiguration->mode, 'assetBaseUrl' => $assetBaseUrl, 'manifestPath' => $viteConfiguration->manifestPath, 'devServerUrl' => $devServerUrl, diff --git a/src/Render/SugarPageRendererFactory.php b/src/Render/SugarPageRendererFactory.php index 3488549..807d150 100644 --- a/src/Render/SugarPageRendererFactory.php +++ b/src/Render/SugarPageRendererFactory.php @@ -46,13 +46,17 @@ public function create( bool $debug = false, ?EventDispatcher $dispatcher = null, ): SugarPageRenderer { + $viteOptions = $debug + ? $config->templateViteOptions->applyEnvironmentOverrides() + : $config->templateViteOptions; + $renderer = new SugarPageRenderer( templatePath: $config->templatePath(), cachePath: $config->cachePath(CachePath::Sugar), template: $template, siteConfig: $config->site, resourcePathRewriter: $this->resourcePathRewriter, - templateVite: $config->templateViteOptions, + templateVite: $viteOptions, debug: $debug, ); diff --git a/tests/Unit/ApplicationTest.php b/tests/Unit/ApplicationTest.php index 4d6215d..a35d21a 100644 --- a/tests/Unit/ApplicationTest.php +++ b/tests/Unit/ApplicationTest.php @@ -9,12 +9,14 @@ use Glaze\Application; use Glaze\Config\BuildConfig; use Glaze\Config\ProjectConfigurationReader; +use Glaze\Http\DevPageRequestHandler; use Glaze\Http\Middleware\ContentAssetMiddleware; use Glaze\Http\Middleware\ControllerMiddleware; use Glaze\Http\Middleware\CoreAssetMiddleware; use Glaze\Http\Middleware\ErrorHandlingMiddleware; use Glaze\Http\Middleware\PublicAssetMiddleware; use Glaze\Http\Middleware\StaticAssetMiddleware; +use Glaze\Http\StaticPageRequestHandler; use Glaze\Image\ImageTransformerInterface; use PHPUnit\Framework\TestCase; @@ -172,4 +174,36 @@ public function testMiddlewareRegistersExpectedStaticStack(): void ContentAssetMiddleware::class, ], array_map(static fn(object $middleware): string => $middleware::class, $middlewares)); } + + /** + * Ensure fallbackHandler() returns the dev-mode handler when static mode is off. + */ + public function testFallbackHandlerReturnsDevHandlerInLiveMode(): void + { + $application = new Application(); + $application->bootstrap(); + + (new ProjectConfigurationReader())->read('/tmp/glaze-project'); + Configure::write('projectRoot', '/tmp/glaze-project'); + + $fallbackHandler = $application->fallbackHandler(false); + + $this->assertInstanceOf(DevPageRequestHandler::class, $fallbackHandler); + } + + /** + * Ensure fallbackHandler() returns the static-mode handler when static mode is on. + */ + public function testFallbackHandlerReturnsStaticHandlerInStaticMode(): void + { + $application = new Application(); + $application->bootstrap(); + + (new ProjectConfigurationReader())->read('/tmp/glaze-project'); + Configure::write('projectRoot', '/tmp/glaze-project'); + + $fallbackHandler = $application->fallbackHandler(true); + + $this->assertInstanceOf(StaticPageRequestHandler::class, $fallbackHandler); + } } diff --git a/tests/Unit/Config/TemplateViteOptionsTest.php b/tests/Unit/Config/TemplateViteOptionsTest.php index 95ab716..729bf04 100644 --- a/tests/Unit/Config/TemplateViteOptionsTest.php +++ b/tests/Unit/Config/TemplateViteOptionsTest.php @@ -25,6 +25,7 @@ public function testDefaultConstructorCarriesExpectedDefaults(): void $this->assertSame('http://127.0.0.1:5173', $options->devServerUrl); $this->assertTrue($options->injectClient); $this->assertNull($options->defaultEntry); + $this->assertSame('auto', $options->mode); } /** @@ -40,6 +41,31 @@ public function testFromProjectConfigWithEmptyArraysUsesDefaults(): void $this->assertSame('http://127.0.0.1:5173', $options->devServerUrl); $this->assertTrue($options->injectClient); $this->assertNull($options->defaultEntry); + $this->assertSame('auto', $options->mode); + } + + /** + * Ensure a valid `mode` in the build config block is read and preserved. + */ + public function testFromProjectConfigReadsExplicitMode(): void + { + $optionsDev = TemplateViteOptions::fromProjectConfig(['mode' => 'dev'], [], '/project'); + $optionsProd = TemplateViteOptions::fromProjectConfig(['mode' => 'prod'], [], '/project'); + $optionsAuto = TemplateViteOptions::fromProjectConfig(['mode' => 'auto'], [], '/project'); + + $this->assertSame('dev', $optionsDev->mode); + $this->assertSame('prod', $optionsProd->mode); + $this->assertSame('auto', $optionsAuto->mode); + } + + /** + * Ensure an invalid or unrecognised `mode` value falls back to `auto`. + */ + public function testFromProjectConfigInvalidModeFallsBackToAuto(): void + { + $options = TemplateViteOptions::fromProjectConfig(['mode' => 'invalid'], [], '/project'); + + $this->assertSame('auto', $options->mode); } /** @@ -286,4 +312,88 @@ public function testEmptyDefaultEntryFallsBackToNull(): void $this->assertNull($options->defaultEntry); } + + /** + * Ensure applyEnvironmentOverrides returns the same options when no env vars are set. + */ + public function testApplyEnvironmentOverridesReturnsUnchangedOptionsWhenNoEnvVarsAreSet(): void + { + $original = new TemplateViteOptions(devEnabled: true, devServerUrl: 'http://127.0.0.1:5173'); + $result = $original->applyEnvironmentOverrides(); + + $this->assertTrue($result->devEnabled); + $this->assertSame('http://127.0.0.1:5173', $result->devServerUrl); + } + + /** + * Ensure GLAZE_VITE_ENABLED=1 forces devEnabled on regardless of config. + */ + public function testApplyEnvironmentOverridesEnablesDevWhenEnvVarIsOne(): void + { + putenv('GLAZE_VITE_ENABLED=1'); + + try { + $result = (new TemplateViteOptions(devEnabled: false))->applyEnvironmentOverrides(); + + $this->assertTrue($result->devEnabled); + } finally { + putenv('GLAZE_VITE_ENABLED'); + } + } + + /** + * Ensure GLAZE_VITE_ENABLED=0 forces devEnabled off regardless of config. + */ + public function testApplyEnvironmentOverridesDisablesDevWhenEnvVarIsZero(): void + { + putenv('GLAZE_VITE_ENABLED=0'); + + try { + $result = (new TemplateViteOptions(devEnabled: true))->applyEnvironmentOverrides(); + + $this->assertFalse($result->devEnabled); + } finally { + putenv('GLAZE_VITE_ENABLED'); + } + } + + /** + * Ensure GLAZE_VITE_URL replaces devServerUrl when set. + */ + public function testApplyEnvironmentOverridesReplacesDevServerUrlFromEnv(): void + { + putenv('GLAZE_VITE_URL=http://127.0.0.1:9999'); + + try { + $result = (new TemplateViteOptions(devServerUrl: 'http://127.0.0.1:5173')) + ->applyEnvironmentOverrides(); + + $this->assertSame('http://127.0.0.1:9999', $result->devServerUrl); + } finally { + putenv('GLAZE_VITE_URL'); + } + } + + /** + * Ensure all other fields are preserved unchanged by applyEnvironmentOverrides. + */ + public function testApplyEnvironmentOverridesPreservesAllOtherFields(): void + { + $original = new TemplateViteOptions( + buildEnabled: true, + assetBaseUrl: '/_glaze/assets/', + manifestPath: '/some/path/manifest.json', + injectClient: false, + defaultEntry: 'assets/app.js', + ); + + $result = $original->applyEnvironmentOverrides(); + + $this->assertSame($original->buildEnabled, $result->buildEnabled); + $this->assertSame($original->assetBaseUrl, $result->assetBaseUrl); + $this->assertSame($original->manifestPath, $result->manifestPath); + $this->assertSame($original->injectClient, $result->injectClient); + $this->assertSame($original->defaultEntry, $result->defaultEntry); + $this->assertSame($original->mode, $result->mode); + } } diff --git a/tests/Unit/Http/Routing/ControllerViewRendererTest.php b/tests/Unit/Http/Routing/ControllerViewRendererTest.php index 7b69051..ac4d4e2 100644 --- a/tests/Unit/Http/Routing/ControllerViewRendererTest.php +++ b/tests/Unit/Http/Routing/ControllerViewRendererTest.php @@ -4,12 +4,14 @@ namespace Glaze\Tests\Unit\Http\Routing; use Glaze\Config\BuildConfig; +use Glaze\Config\TemplateViteOptions; use Glaze\Http\Routing\ControllerViewRenderer; use Glaze\Http\Routing\MatchedRoute; use Glaze\Support\ResourcePathRewriter; use Glaze\Tests\Helper\ContainerTestTrait; use Glaze\Tests\Helper\FilesystemTestTrait; use PHPUnit\Framework\TestCase; +use ReflectionProperty; /** * Tests for ControllerViewRenderer template resolution and rendering. @@ -19,6 +21,46 @@ final class ControllerViewRendererTest extends TestCase use ContainerTestTrait; use FilesystemTestTrait; + /** + * Ensure the default backend Vite options target the package's own Vite build (port 5184). + */ + public function testDefaultBackendViteOptionsTargetPackageBuild(): void + { + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/content', 0755, true); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + $renderer = $this->makeRenderer($config); + + $vite = (new ReflectionProperty(ControllerViewRenderer::class, 'backendVite'))->getValue($renderer); + + $this->assertInstanceOf(TemplateViteOptions::class, $vite); + $this->assertTrue($vite->buildEnabled); + $this->assertTrue($vite->devEnabled); + $this->assertStringEndsWith('/resources/backend/assets/dist/.vite/manifest.json', $vite->manifestPath); + $this->assertSame('http://localhost:5174', $vite->devServerUrl); + $this->assertSame('/_glaze/assets/dist/', $vite->assetBaseUrl); + $this->assertSame('dev', $vite->mode); + } + + /** + * Ensure the backendVite property can be overridden via reflection for testing purposes. + */ + public function testBackendViteOptionsCanBeOverriddenViaReflection(): void + { + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/content', 0755, true); + $config = BuildConfig::fromProjectRoot($projectRoot, true); + $renderer = $this->makeRenderer($config); + + $customVite = new TemplateViteOptions(devServerUrl: 'http://127.0.0.1:9999'); + (new ReflectionProperty(ControllerViewRenderer::class, 'backendVite'))->setValue($renderer, $customVite); + + $vite = (new ReflectionProperty(ControllerViewRenderer::class, 'backendVite'))->getValue($renderer); + + $this->assertSame($customVite, $vite); + $this->assertSame('http://127.0.0.1:9999', $vite->devServerUrl); + } + /** * Ensure hasTemplate returns false when no template file exists. */ diff --git a/tests/Unit/Render/SugarPageRendererTest.php b/tests/Unit/Render/SugarPageRendererTest.php index 86ec8c2..7383728 100644 --- a/tests/Unit/Render/SugarPageRendererTest.php +++ b/tests/Unit/Render/SugarPageRendererTest.php @@ -9,6 +9,7 @@ use Glaze\Support\ResourcePathRewriter; use Glaze\Tests\Helper\FilesystemTestTrait; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use Sugar\Core\Cache\FileCache; use Sugar\Core\Loader\FileTemplateLoader; @@ -50,4 +51,97 @@ public function testGetLoaderAndGetCacheReturnStableInstances(): void $this->assertInstanceOf(FileCache::class, $cacheOne); $this->assertSame($cacheOne, $cacheTwo); } + + /** + * Ensure resolveViteConfiguration returns null when Vite is disabled. + */ + public function testResolveViteConfigurationReturnsNullWhenDisabled(): void + { + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/templates', 0755, true); + + $renderer = new SugarPageRenderer( + templatePath: $projectRoot . '/templates', + cachePath: $projectRoot . '/tmp/cache/sugar', + template: 'page', + siteConfig: new SiteConfig(), + resourcePathRewriter: new ResourcePathRewriter(), + templateVite: new TemplateViteOptions(devEnabled: false), + debug: true, + ); + + $result = (new ReflectionMethod(SugarPageRenderer::class, 'resolveViteConfiguration')) + ->invoke($renderer); + + $this->assertNull($result); + } + + /** + * Ensure resolveViteConfiguration returns the configured devServerUrl without modification. + */ + public function testResolveViteConfigurationReturnsConfiguredDevServerUrl(): void + { + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/templates', 0755, true); + + $renderer = new SugarPageRenderer( + templatePath: $projectRoot . '/templates', + cachePath: $projectRoot . '/tmp/cache/sugar', + template: 'page', + siteConfig: new SiteConfig(), + resourcePathRewriter: new ResourcePathRewriter(), + templateVite: new TemplateViteOptions(devEnabled: true, devServerUrl: 'http://127.0.0.1:5184'), + debug: true, + ); + + $result = (new ReflectionMethod(SugarPageRenderer::class, 'resolveViteConfiguration')) + ->invoke($renderer); + + $this->assertIsArray($result); + $this->assertSame('http://127.0.0.1:5184', $result['devServerUrl']); + $this->assertSame('auto', $result['mode']); + } + + /** + * Ensure resolveViteConfiguration passes an explicit mode through unmodified. + */ + public function testResolveViteConfigurationPassesThroughExplicitMode(): void + { + $projectRoot = $this->createTempDirectory(); + mkdir($projectRoot . '/templates', 0755, true); + + foreach (['dev', 'prod', 'auto'] as $mode) { + $options = new TemplateViteOptions(devEnabled: true, buildEnabled: true, mode: $mode); + + $rendererDev = new SugarPageRenderer( + templatePath: $projectRoot . '/templates', + cachePath: $projectRoot . '/tmp/cache/sugar', + template: 'page', + siteConfig: new SiteConfig(), + resourcePathRewriter: new ResourcePathRewriter(), + templateVite: $options, + debug: true, + ); + + $rendererProd = new SugarPageRenderer( + templatePath: $projectRoot . '/templates', + cachePath: $projectRoot . '/tmp/cache/sugar', + template: 'page', + siteConfig: new SiteConfig(), + resourcePathRewriter: new ResourcePathRewriter(), + templateVite: $options, + debug: false, + ); + + $resultDev = (new ReflectionMethod(SugarPageRenderer::class, 'resolveViteConfiguration')) + ->invoke($rendererDev); + $resultProd = (new ReflectionMethod(SugarPageRenderer::class, 'resolveViteConfiguration')) + ->invoke($rendererProd); + + $this->assertIsArray($resultDev); + $this->assertSame($mode, $resultDev['mode'], "debug=true, mode={$mode}"); + $this->assertIsArray($resultProd); + $this->assertSame($mode, $resultProd['mode'], "debug=false, mode={$mode}"); + } + } } From 937641ec92348549e7d74289ecbc43fd74d552dc Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Mon, 16 Mar 2026 21:56:02 +0100 Subject: [PATCH 9/9] refactor terminology between live mode and debug mode --- bin/dev-router.php | 2 + docs/content/reference/commands.dj | 1 + docs/content/reference/recipes.dj | 30 +++++----- docs/content/templating/index.dj | 4 +- .../backend/assets/dist/.vite/manifest.json | 7 +++ .../assets/dist/assets/dev-BBpMC4gS.css | 1 + resources/backend/vite.config.js | 2 +- src/Application.php | 12 ++-- src/Build/SiteBuilder.php | 4 +- src/Command/ServeCommand.php | 11 +++- src/Config/TemplateViteOptions.php | 24 +++++++- src/Http/Routing/ControllerViewRenderer.php | 9 ++- src/Process/PhpServerProcess.php | 1 + src/Render/PageRenderPipeline.php | 15 +++-- src/Render/SugarPageRenderer.php | 29 ++++++--- src/Render/SugarPageRendererFactory.php | 28 +++++---- src/Template/SiteContext.php | 13 ++++ tests/Unit/Build/SiteBuilderTest.php | 8 +++ tests/Unit/Command/ServeCommandTest.php | 8 ++- tests/Unit/Config/TemplateViteOptionsTest.php | 30 ++++++++++ .../Routing/ControllerViewRendererTest.php | 2 +- tests/Unit/Process/PhpServerProcessTest.php | 4 ++ tests/Unit/Render/PageRenderPipelineTest.php | 60 ++++++++++++++++--- tests/Unit/Render/SugarPageRendererTest.php | 59 ++++++++++++++---- tests/Unit/Template/SiteContextTest.php | 18 ++++++ 25 files changed, 307 insertions(+), 75 deletions(-) create mode 100644 resources/backend/assets/dist/.vite/manifest.json create mode 100644 resources/backend/assets/dist/assets/dev-BBpMC4gS.css diff --git a/bin/dev-router.php b/bin/dev-router.php index 9289450..0871238 100644 --- a/bin/dev-router.php +++ b/bin/dev-router.php @@ -24,12 +24,14 @@ $includeDrafts = getenv('GLAZE_INCLUDE_DRAFTS') === '1'; $staticMode = getenv('GLAZE_STATIC_MODE') === '1'; +$debug = getenv('GLAZE_DEBUG') === '1'; $application = new Application(); $application->bootstrap(); (new ProjectConfigurationReader())->read($projectRoot); Configure::write('projectRoot', $projectRoot); +Configure::write('debug', $debug); if ($includeDrafts) { Configure::write('build.drafts', true); } diff --git a/docs/content/reference/commands.dj b/docs/content/reference/commands.dj index fb52188..336d3cd 100644 --- a/docs/content/reference/commands.dj +++ b/docs/content/reference/commands.dj @@ -174,6 +174,7 @@ Options: - `--static` serve prebuilt `public/` - `--build` prebuild before static serve (`--static` required) - `--drafts` include drafts for static mode +- `--debug` enable debug mode - `--vite` enable Vite integration in live mode - `--vite-host ` Vite host (default `127.0.0.1`) - `--vite-port ` Vite port (default `5173`) diff --git a/docs/content/reference/recipes.dj b/docs/content/reference/recipes.dj index 989238a..441e277 100644 --- a/docs/content/reference/recipes.dj +++ b/docs/content/reference/recipes.dj @@ -7,16 +7,17 @@ weight: 50 Short, copy-paste patterns for common real-world needs. -## Environment-aware templates with `$debug` +## Environment-aware templates with `$this->isLiveMode()` -Every template receives a `$debug` boolean that is `true` when Glaze is running in -`glaze serve` (development) mode and `false` during `glaze build` (production). +Inside templates, `$this` is a `SiteContext` object. Use `$this->isLiveMode()` to +detect whether Glaze is running in `glaze serve` (live mode) or `glaze build` +(static build mode). This lets you keep a single template codebase while varying content by environment — no separate config files required. ::: info -`$debug` maps 1-to-1 with the `glaze serve` / `glaze build` distinction +`$this->isLiveMode()` maps 1-to-1 with the `glaze serve` / `glaze build` distinction — it is not affected by your NEON config, OS environment variables, or Vite settings. ::: @@ -26,7 +27,7 @@ Keep your development credentials out of production builds (and vice versa): ```php isLiveMode() ? 'pk_test_xxxxxxxxxxxxxxxx' // development key : 'pk_live_xxxxxxxxxxxxxxxx'; // production key ?> @@ -48,7 +49,7 @@ site: ```php isLiveMode() ? $site->siteMeta('apiKeyDev') : $site->siteMeta('apiKeyLive'); ?> @@ -63,7 +64,7 @@ Emit the GTM snippet only when generating the static build so analytics data is never polluted by local development traffic: ```php - +isLiveMode()): ?>