$error, 'success' => $success]; } // ── Redirect ────────────────────────────────────────────────────────────── /** * Flash a message and redirect. Terminates the script. */ public static function redirect(string $url, ?string $success = null, ?string $error = null): never { if ($success !== null) { self::flash('success', $success); } if ($error !== null) { self::flash('error', $error); } header('Location: ' . $url); exit; } // ── Asset versioning ───────────────────────────────────────────────────── /** * Return an asset URL with a filemtime-based cache-busting query string. * Input is a root-relative URL path (e.g. /assets/css/main.css). */ public static function assetV(string $urlPath): string { $file = APP_ROOT . '/public' . $urlPath; $v = file_exists($file) ? filemtime($file) : 0; return $urlPath . ($v ? '?v=' . $v : ''); } // ── Template rendering ──────────────────────────────────────────────────── /** * Render a full page: head → header → content template → footer. * * Expects $vars to contain the same keys the templates already rely on * ($pageTitle, $bodyClass, $isAdmin, $extraCss, $ogTags, etc.). * The footer variant (public vs admin) is chosen automatically based * on $isAdmin. * * @param string $template Path relative to APP_ROOT/templates/ * @param array $vars Variables to expose inside the templates */ public static function render(string $template, array $vars = []): void { // Make all vars available in the template scope. extract($vars); include APP_ROOT . '/templates/head.php'; include APP_ROOT . '/templates/header.php'; include APP_ROOT . '/templates/' . $template; if (!empty($isAdmin)) { include APP_ROOT . '/templates/admin/footer.php'; } else { include APP_ROOT . '/templates/footer.php'; } } }