twig = new Environment($loader, [ 'cache' => $cachePath, 'debug' => $isDev, 'auto_reload' => $isDev, 'strict_variables' => false, 'autoescape' => 'html', ]); if ($isDev) { $this->twig->addExtension(new DebugExtension()); } $this->registerFunctions(); $this->registerFilters(); } public static function getInstance(): self { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } public function getEnvironment(): Environment { return $this->twig; } /** * Render a Twig template with data + automatically injected globals. */ public function render(string $template, array $data = []): string { $globals = $this->getGlobals(); $context = array_merge($globals, $data); return $this->twig->render($template, $context); } /** * Collect layout-level data that every template may need. * Computed on each render so values are always fresh. */ private function getGlobals(): array { // Session flash messages (read & clear) — always safe $flash = []; foreach (['success', 'error', 'warning', 'info'] as $type) { if (isset($_SESSION[$type])) { $flash[$type] = $_SESSION[$type]; unset($_SESSION[$type]); } } // Database-dependent globals are wrapped in try/catch so standalone // pages (installer, error pages) still render when the DB is absent. try { $userId = Auth::id(); if ($userId) { $notificationData = \App\Helpers\LayoutHelper::getNotifications($userId); $recentNotifications = $notificationData['items']; $unreadNotifications = $notificationData['unread_count']; $updateBadge = Auth::isAdmin() ? \App\Helpers\LayoutHelper::getUpdateBadgeInfo() : ['show' => false, 'available' => false, 'label' => '']; } else { $recentNotifications = []; $unreadNotifications = 0; $updateBadge = ['show' => false, 'available' => false, 'label' => '']; } $domainStats = \App\Helpers\LayoutHelper::getDomainStats(); $appSettings = \App\Helpers\LayoutHelper::getAppSettings(); $avatar = null; if ($userId) { $userModel = new \App\Models\User(); $user = $userModel->find($userId); if ($user) { $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 36); } } return [ 'auth' => [ 'check' => Auth::check(), 'id' => $userId, 'username' => Auth::username(), 'fullName' => Auth::fullName(), 'role' => Auth::role(), 'isAdmin' => Auth::isAdmin(), ], 'session' => $_SESSION ?? [], 'flash' => $flash, 'recentNotifications' => $recentNotifications, 'unreadNotifications' => $unreadNotifications, 'updateBadge' => $updateBadge, 'domainStats' => $domainStats, 'appName' => $appSettings['app_name'], 'appTimezone' => $appSettings['app_timezone'], 'appVersion' => $appSettings['app_version'], 'avatar' => $avatar, 'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/', 'appEnv' => $_ENV['APP_ENV'] ?? 'development', ]; } catch (\Throwable $e) { return [ 'auth' => ['check' => false, 'id' => null, 'username' => '', 'fullName' => '', 'role' => '', 'isAdmin' => false], 'session' => $_SESSION ?? [], 'flash' => $flash, 'recentNotifications' => [], 'unreadNotifications' => 0, 'updateBadge' => ['show' => false, 'available' => false, 'label' => ''], 'domainStats' => [], 'appName' => 'Domain Monitor', 'appTimezone' => 'UTC', 'appVersion' => '', 'avatar' => null, 'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/', 'appEnv' => $_ENV['APP_ENV'] ?? 'development', ]; } } private function registerFunctions(): void { $this->twig->addFunction(new TwigFunction('csrf_field', function (): string { return \Core\Csrf::field(); }, ['is_safe' => ['html']])); $this->twig->addFunction(new TwigFunction('csrf_token', function (): string { return \Core\Csrf::getToken(); })); $this->twig->addFunction(new TwigFunction('old', function (string $key, string $default = ''): string { return htmlspecialchars($_POST[$key] ?? $default, ENT_QUOTES, 'UTF-8'); })); $this->twig->addFunction(new TwigFunction('asset', function (string $path): string { return '/' . ltrim($path, '/'); })); $this->twig->addFunction(new TwigFunction('is_active', function (string $path): bool { $current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); return $current === $path; })); $this->twig->addFunction(new TwigFunction('is_active_prefix', function (string $prefix): bool { $current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); return str_starts_with($current, $prefix); })); $this->twig->addFunction(new TwigFunction('sort_url', function (string $column, string $currentSort, string $currentOrder, array $filters = []): string { return \App\Helpers\ViewHelper::sortUrl($column, $currentSort, $currentOrder, $filters); })); $this->twig->addFunction(new TwigFunction('sort_icon', function (string $column, string $currentSort, string $currentOrder): string { return \App\Helpers\ViewHelper::sortIcon($column, $currentSort, $currentOrder); }, ['is_safe' => ['html']])); $this->twig->addFunction(new TwigFunction('pagination_url', function (int $page, array $filters, int $perPage): string { return \App\Helpers\ViewHelper::paginationUrl($page, $filters, $perPage); })); $this->twig->addFunction(new TwigFunction('status_badge', function (string $status): string { return \App\Helpers\ViewHelper::statusBadge($status); }, ['is_safe' => ['html']])); $this->twig->addFunction(new TwigFunction('alert', function (string $type, string $message): string { return \App\Helpers\ViewHelper::alert($type, $message); }, ['is_safe' => ['html']])); $this->twig->addFunction(new TwigFunction('breadcrumbs', function (array $items): string { return \App\Helpers\ViewHelper::breadcrumbs($items); }, ['is_safe' => ['html']])); $this->twig->addFunction(new TwigFunction('format_login_dropdown', function (array $loginData): string { return \App\Helpers\LayoutHelper::formatLoginDropdown($loginData); })); $this->twig->addFunction(new TwigFunction('max_upload_size', function (): string { return \App\Helpers\ViewHelper::getMaxUploadSize(); })); $this->twig->addFunction(new TwigFunction('role_badge', function (string $role, string $size = 'sm'): string { $isAdmin = $role === 'admin'; $color = $isAdmin ? 'amber' : 'blue'; $icon = $isAdmin ? 'crown' : 'user'; $label = ucfirst($role); if ($size === 'xs') { $padding = 'px-2 py-0.5'; } else { $padding = 'px-2.5 py-1'; } return '' . '' . htmlspecialchars($label) . ''; }, ['is_safe' => ['html']])); } private function registerFilters(): void { $this->twig->addFilter(new TwigFilter('truncate', function (string $text, int $length = 50, string $suffix = '...'): string { return \App\Helpers\ViewHelper::truncate($text, $length, $suffix); })); $this->twig->addFilter(new TwigFilter('format_bytes', function (int $bytes, int $precision = 2): string { return \App\Helpers\ViewHelper::formatBytes($bytes, $precision); })); $this->twig->addFilter(new TwigFilter('safe_url', function (?string $url): string { if ($url === null || $url === '') { return '#'; } if (preg_match('#^https?://#i', $url)) { return $url; } return '#'; })); $this->twig->addFilter(new TwigFilter('safe_mailto', function (?string $email): string { if ($email === null || $email === '') { return '#'; } if (filter_var($email, FILTER_VALIDATE_EMAIL)) { return 'mailto:' . $email; } return '#'; })); $this->twig->addFilter(new TwigFilter('from_json', function ($value) { if ($value === null || $value === '') { return []; } if (is_array($value) || is_object($value)) { return $value; } $decoded = json_decode((string) $value, true); return $decoded ?? []; })); } }