config = $config; // Load version information $versionFile = __DIR__ . '/../../../version.php'; if (file_exists($versionFile)) { $this->config['version_info'] = include $versionFile; } $this->currentLanguage = $this->getCurrentLanguage(); $this->translations = $this->loadTranslations($this->currentLanguage); // Initialize plugin manager (files already loaded in cms/core/index.php) $enabledPlugins = $this->config['enabled_plugins'] ?? []; $this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins); $api = new CMSAPI($this); $this->pluginManager->setAPI($api); $this->buildMenu(); if (isset($_GET['search'])) { $this->performSearch($_GET['search']); } } /** * Build a clean URL for a page * * @param string $page Page path (e.g., 'blog/leren/artikel') * @param string|null $lang Language code * @param array $params Additional query parameters * @return string Clean URL */ public function buildUrl($page = null, $lang = null, $params = []) { $lang = $lang ?: $this->currentLanguage; $url = '/' . $lang; // Only omit the page segment for the actual homepage (default_page), // not for a page that happens to be called 'index' if ($page && $page !== $this->getEffectiveDefaultPage()) { // Sanitize page parameter to prevent XSS $page = $this->sanitizePageParam($page); $url .= '/' . $page; } if (!empty($params)) { $url .= '?' . http_build_query($params); } return $url; } /** * Sanitize page parameter to prevent XSS attacks * Removes any characters that are not alphanumeric, dashes, underscores, or slashes */ private function sanitizePageParam(string $page): string { // Remove any characters that could be used for XSS $sanitized = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $page); return $sanitized ?: 'invalid-page'; } /** * Resolve the effective default page (handles 'auto' mode) * * @return string Page key that is served on the language root URL */ public function getEffectiveDefaultPage(): string { if ($this->effectiveDefaultPage === null) { $page = $this->config['default_page'] ?? 'auto'; $this->effectiveDefaultPage = match ($page) { 'auto' => $this->detectDefaultPage(), 'newest' => $this->detectNewestPage(), default => $page, }; } return $this->effectiveDefaultPage; } /** * Build a clean admin URL * * @param string $route Admin route * @param array $params Additional query parameters * @return string Clean admin URL */ public static function buildAdminUrl($route = '', $params = []) { $url = $route ? '/admin/' . $route : '/admin'; if (!empty($params)) { $url .= '?' . http_build_query($params); } return $url; } /** * Build URL string for template use (non-static, with current lang context) */ public function url($page = 'index', $extraParams = []) { return $this->buildUrl($page, $this->currentLanguage, $extraParams); } /** * Get current language from request or config * * @return string Current language code */ private function getCurrentLanguage() { $lang = $_GET['lang'] ?? $this->config['language']['default'] ?? 'nl'; // Validate language parameter to prevent XSS $allowedLanguages = ['nl', 'en']; return in_array($lang, $allowedLanguages) ? $lang : ($this->config['language']['default'] ?? 'nl'); } /** * Get all available languages from lang directory * * @return array Available languages with their codes and names */ public function getAvailableLanguages() { $langDir = __DIR__ . '/../../lang/'; $languages = []; if (!is_dir($langDir)) { return $languages; } $files = scandir($langDir); foreach ($files as $file) { if (preg_match('/^([a-z]{2})\.php$/', $file, $matches)) { $langCode = $matches[1]; $langFile = $langDir . $file; if (file_exists($langFile)) { $translations = include $langFile; $languages[$langCode] = [ 'code' => $langCode, 'name' => $translations['site_title'] ?? strtoupper($langCode), 'native_name' => $this->getNativeLanguageName($langCode) ]; } } } return $languages; } /** * Load translations for specified language * * @param string $lang Language code * @return array Translations array */ private function loadTranslations($lang) { $langFile = __DIR__ . '/../../lang/' . $lang . '.php'; if (file_exists($langFile)) { $translations = include $langFile; return $translations; } // Fallback to default language $defaultLang = $this->config['language']['default'] ?? 'nl'; $defaultLangFile = __DIR__ . '/../../lang/' . $defaultLang . '.php'; if (file_exists($defaultLangFile)) { return include $defaultLangFile; } // Return empty array if no translation found return []; } /** * Get native language name for language code * * @param string $langCode Language code * @return string Native language name */ private function getNativeLanguageName($langCode) { $names = [ 'nl' => 'Nederlands', 'en' => 'English', 'fr' => 'Français', 'de' => 'Deutsch', 'es' => 'Español', 'it' => 'Italiano', 'pt' => 'Português', 'ru' => 'Русский', 'zh' => '中文', 'ja' => '日本語', 'ar' => 'العربية' ]; return $names[$langCode] ?? strtoupper($langCode); } /** * Get translated text * * @param string $key Translation key * @return string Translated text */ public function t($key) { return $this->translations[$key] ?? $key; } /** * Build menu structure from content directory * * @return void */ private function buildMenu() { $this->menu = $this->scanDirectory($this->config['content_dir'], ''); $this->pluginManager->doAction('onMenuBuild', $this->menu); } /** * Recursively scan directory for content files and folders * * @param string $dir Directory path to scan * @param string $prefix Relative path prefix * @return array Array of menu items */ private function scanDirectory($dir, $prefix) { if (!is_dir($dir)) return []; $items = scandir($dir); sort($items); $result = []; foreach ($items as $item) { if ($item[0] === '.' || $item[0] === '-') continue; // Skip assets directory (old name, kept for safety) if ($item === 'assets' && is_dir($dir . '/' . $item)) continue; // Skip language-specific content that doesn't match current language $availableLangs = array_keys($this->getAvailableLanguages()); $langPattern = '/^(' . implode('|', $availableLangs) . ')\./'; if (preg_match($langPattern, $item, $langMatch)) { if ($langMatch[1] !== $this->currentLanguage) { continue; } } $path = $dir . '/' . $item; $relativePath = $prefix ? $prefix . '/' . $item : $item; if (is_dir($path)) { $result[] = [ 'type' => 'folder', 'title' => $this->formatDisplayName($item), 'path' => $relativePath, 'children' => $this->scanDirectory($path, $relativePath) ]; } elseif (preg_match('/\.(md|php|html)$/', $item)) { // Always use filename for navigation (not H1 titles from content) $filename = pathinfo($item, PATHINFO_FILENAME); $title = $this->formatDisplayName($filename); $pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath); $result[] = [ 'type' => 'file', 'title' => $title, 'path' => $pathWithoutExt, 'url' => $this->buildUrl($pathWithoutExt) ]; } } return $result; } /** * Perform search across all content files * * @param string $query Search query string * @return void */ private function performSearch($query) { $this->searchResults = []; $this->searchInDirectory($this->config['content_dir'], '', $query); $this->pluginManager->doAction('onSearch', $query, $this->searchResults); } /** * Recursively search for query in directory files * * @param string $dir Directory to search in * @param string $prefix Relative path prefix * @param string $query Search query * @return void */ private function searchInDirectory($dir, $prefix, $query) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item[0] === '.') continue; $path = $dir . '/' . $item; $relativePath = $prefix ? $prefix . '/' . $item : $item; if (is_dir($path)) { $this->searchInDirectory($path, $relativePath, $query); } elseif (preg_match('/\.(md|php|html)$/', $item)) { $content = file_get_contents($path); if (stripos($content, $query) !== false || stripos($item, $query) !== false) { $title = ucfirst(pathinfo($item, PATHINFO_FILENAME)); $this->searchResults[] = [ 'title' => $title, 'path' => $relativePath, 'url' => $this->buildUrl($relativePath), 'snippet' => $this->createSnippet($content, $query) ]; } } } } /** * Create search snippet with highlighted query * * @param string $content Full content to create snippet from * @param string $query Search query to highlight * @return string Formatted snippet */ private function createSnippet($content, $query) { $content = strip_tags($content); $pos = stripos($content, $query); if ($pos === false) return substr($content, 0, 100) . '...'; $start = max(0, $pos - 50); $snippet = substr($content, $start, 150); return '...' . $snippet . '...'; } /** * Get current page content based on request * * @return array Page data with title and content */ public function getPage() { if (isset($_GET['search'])) { return $this->getSearchResults(); } // Check if guide is requested (either via ?guide or /guide clean URL) $pageCheck = $_GET['page'] ?? ''; if (isset($_GET['guide']) || $pageCheck === 'guide') { return $this->getGuidePage(); } // Check if content directory is empty if ($this->isContentDirEmpty()) { return $this->getGuidePage(); } $page = $_GET['page'] ?? $this->getEffectiveDefaultPage(); // Limit length $page = substr($page, 0, 255); // Only remove file extension at the end, not all dots $pageWithoutExt = preg_replace('/\.(md|php|html)$/', '', $page); $filePath = $this->config['content_dir'] . '/' . $pageWithoutExt; // Prevent path traversal using realpath validation $realContentDir = realpath($this->config['content_dir']); $realFilePath = realpath($filePath); if ($realFilePath && $realContentDir && strpos($realFilePath, $realContentDir) !== 0) { return $this->getError404(); } // Check if directory exists FIRST (directories take precedence over files) if (is_dir($filePath)) { return $this->getDirectoryListing($pageWithoutExt, $filePath); } $actualFilePath = null; // Check for exact file matches if no directory found if (file_exists($filePath . '.md')) { $actualFilePath = $filePath . '.md'; $result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath); } elseif (file_exists($filePath . '.php')) { $actualFilePath = $filePath . '.php'; $result = $this->parsePHP($actualFilePath); } elseif (file_exists($filePath . '.html')) { $actualFilePath = $filePath . '.html'; $result = $this->parseHTML(file_get_contents($actualFilePath)); } elseif (file_exists($filePath)) { $actualFilePath = $filePath; $extension = pathinfo($filePath, PATHINFO_EXTENSION); if ($extension === 'md') { $result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath); } elseif ($extension === 'php') { $result = $this->parsePHP($actualFilePath); } elseif ($extension === 'html') { $result = $this->parseHTML(file_get_contents($actualFilePath)); } } // If no exact match found, check for language-specific versions if (!isset($result)) { $result = null; // Reset result before language-specific search $langPrefix = $this->currentLanguage; if (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md')) { $actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md'; $result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath); } elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php')) { $actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php'; $result = $this->parsePHP($actualFilePath); } elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html')) { $actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html'; $result = $this->parseHTML(file_get_contents($actualFilePath)); } } // If no file found, check if it's a directory (directories take precedence) if (!isset($result) && is_dir($filePath)) { return $this->getDirectoryListing($pageWithoutExt, $filePath); } if (isset($result) && $actualFilePath) { $result['file_info'] = $this->getFileInfo($actualFilePath, $result['metadata'] ?? []); return $result; } return $this->getError404(); } /** * Get file information including creation and modification dates * * @param string $filePath Path to the file * @param array $metadata Optional frontmatter metadata * @return array|null File information or null if file doesn't exist */ private function getFileInfo($filePath, array $metadata = []) { if (!file_exists($filePath)) { return null; } $stats = stat($filePath); $modified = date('d-m-Y H:i', $stats['mtime']); // 1. Check if created date is present in frontmatter metadata $metaCreated = $metadata['created'] ?? $metadata['date'] ?? $metadata['date_created'] ?? $metadata['created_at'] ?? null; if ($metaCreated) { if (is_numeric($metaCreated)) { $created = date('d-m-Y H:i', (int)$metaCreated); } else { $created = (string)$metaCreated; } } else { // 2. Fallback to ctime (inode change time / creation time) or mtime $createdTimestamp = $stats['ctime'] ?? $stats['mtime']; $created = date('d-m-Y H:i', $createdTimestamp); } // Show created date only if it is distinctly different from modified date $showCreated = ($created !== $modified); return [ 'created' => $created, 'modified' => $modified, 'show_created' => $showCreated, 'size' => $this->formatFileSize($stats['size']) ]; } /** * Format file size in human readable format * * @param int $bytes File size in bytes * @return string Formatted file size */ private function formatFileSize($bytes) { $units = ['B', 'KB', 'MB', 'GB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, 2) . ' ' . $units[$pow]; } /** * Get search results page content * * @return array Search results page data */ private function getSearchResults() { $query = $_GET['search']; $content = '
' . $this->t('no_results') . '.
'; } else { $content .= '' . count($this->searchResults) . ' ' . $this->t('results_found') . ':
'; foreach ($this->searchResults as $result) { $content .= '' . htmlspecialchars($result['path']) . '
'; $content .= '' . htmlspecialchars($result['snippet']) . '
'; $content .= 'De gevraagde handleiding is niet gevonden.
', 'layout' => 'content', 'guide_breadcrumbs' => [], 'guide_lang' => $lang, 'guide_page' => $pagePath, ]; } $content = file_get_contents($guideFile); $result = $this->parseMarkdown($content, $guideFile); // Build breadcrumbs $guideBase = '/' . $lang . '/guide'; $breadcrumbs = [['title' => $this->t('manual'), 'url' => $guideBase]]; if (!empty($pagePath)) { $parts = explode('/', $pagePath); $buildPath = ''; foreach ($parts as $part) { $buildPath .= ($buildPath ? '/' : '') . $part; $breadcrumbs[] = [ 'title' => str_replace('-', ' ', ucfirst($part)), 'url' => $guideBase . '?page=' . $buildPath, ]; } } $result['title'] = ($result['metadata']['title'] ?? $this->t('manual')) . ' - CodePress CMS'; $result['layout'] = 'guide'; $result['metadata']['plugins'] = 'Navigation'; $result['guide_breadcrumbs'] = $breadcrumbs; $result['guide_lang'] = $lang; $result['guide_page'] = $pagePath; return $result; } /** * Detect user language from browser headers * * @return string Language code ('nl' or 'en') */ /** * Generate directory listing page * * @param string $pagePath Relative path to directory * @param string $dirPath Absolute path to directory * @return array Directory listing page data */ private function getDirectoryListing($pagePath, $dirPath) { // Get the directory name from the path, not from a potential file $pathParts = explode('/', $pagePath); $dirName = end($pathParts); $title = $this->formatDisplayName($dirName) ?: 'Home'; $content = 'Directory not found.
' ]; } $items = scandir($dirPath); sort($items); $hasContent = false; // Collect all items $allItems = []; foreach ($items as $item) { if ($item[0] === '.') continue; $itemPath = $dirPath . '/' . $item; $relativePath = $pagePath ? $pagePath . '/' . $item : $item; if (is_dir($itemPath)) { $allItems[] = [ 'name' => ucfirst($item), 'path' => $relativePath, 'url' => $this->buildUrl($relativePath), 'icon' => 'bi-folder', 'type' => 'directory' ]; } elseif (preg_match('/\.(md|php|html)$/', $item)) { $extractedTitle = $this->extractPageTitle($itemPath); $fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME)); $pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath); $icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' : (pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark'); $allItems[] = [ 'name' => $fileTitle, 'path' => $pathWithoutExt, 'url' => $this->buildUrl($pathWithoutExt), 'icon' => $icon, 'type' => 'file' ]; } } // Display all items in a single column if (!empty($allItems)) { $content .= '' . $this->t('directory_empty') . '.
'; } // Check for index.md in this directory for layout/plugins metadata $indexFile = $dirPath . '/index.md'; $layout = 'full_content'; $metadata = []; if (file_exists($indexFile)) { $indexContent = file_get_contents($indexFile); $parsed = $this->parseMetadata($indexContent); $metadata = $parsed['metadata']; $layout = $metadata['layout'] ?? 'full_content'; } return [ 'title' => $title, 'content' => $content, 'layout' => $layout, 'metadata' => $metadata, ]; } /** * Get 404 error page content * * @return array 404 page data */ private function getError404() { return [ 'title' => $this->t('page_not_found'), 'content' => '' . $this->t('page_not_found_text') . '
' ]; } /** * Get menu structure * * @return array Menu structure for navigation */ public function getMenu() { return $this->menu; } /** * Render the complete page with template * * @return void */ public function render() { $page = $this->getPage(); $this->pluginManager->doAction('onPageLoad', $page); $this->pluginManager->doAction('onBeforeRender'); $menu = $this->getMenu(); // Get homepage title $homepageTitle = $this->getHomepageTitle(); // Get sidebar content from plugins (filtered per-page if specified in frontmatter) $allowedPlugins = null; if (!empty($page['metadata']['plugins'])) { $allowedPlugins = array_map('trim', explode(',', $page['metadata']['plugins'])); } $sidebarContent = $this->pluginManager->getSidebarContent($allowedPlugins); // Get layout from page metadata $layout = $page['layout'] ?? 'sidebar-content'; // Determine if sidebar toggle should be shown $isGuidePage = isset($_GET['guide']) || ($page['layout'] ?? '') === 'guide'; $hasSidebar = $layout !== 'content' && !empty(trim($sidebarContent)); $breadcrumb = $this->generateBreadcrumb($hasSidebar, $isGuidePage ? $page : null); // Prepare template data $templateData = [ 'site_title' => $this->config['site_title'], 'page_title' => htmlspecialchars($this->pluginManager->applyFilters('onTitleFilter', $page['title'])), 'content' => $this->pluginManager->applyFilters('onContentFilter', $this->processContent($page['content'])), 'sidebar_content' => $sidebarContent, 'layout' => $layout, 'page_metadata' => $page['metadata'] ?? [], 'search_query' => isset($_GET['search']) ? htmlspecialchars($_GET['search']) : '', 'menu' => $this->renderMenu($menu), 'breadcrumb' => $breadcrumb, 'default_page' => $this->getEffectiveDefaultPage(), 'homepage' => $this->getEffectiveDefaultPage(), 'homepage_title' => $homepageTitle, 'is_homepage' => (!isset($_GET['page']) || $_GET['page'] === $this->getEffectiveDefaultPage()), 'home_active_class' => (!isset($_GET['page']) || $_GET['page'] === $this->getEffectiveDefaultPage()) ? 'active' : '', 'is_guide_page' => isset($_GET['guide']), 'lang_switch_url' => '', 'author_name' => $this->config['author']['name'] ?? 'CodePress Developer', 'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#', 'author_git' => 'https://git.noorlander.info/E.Noorlander', 'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system', 'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based', 'block_ai_bots' => !empty($this->config['security']['block_ai_bots']), 'block_search_engines' => !empty($this->config['security']['block_search_engines']), 'cms_version' => ($this->config['show_version'] ?? true) && isset($this->config['version_info']) ? $this->config['version_info']['version'] : '', // Theme colors 'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd', 'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff', 'header_height' => $this->config['theme']['header_height'] ?? '56', 'navigation_color' => $this->config['theme']['navigation_color'] ?? '#f8f9fa', 'navigation_font_color' => $this->config['theme']['navigation_font_color'] ?? '#000000', 'nav_height' => $this->config['theme']['nav_height'] ?? '42', 'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa', 'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6', // Language 'current_lang' => $this->currentLanguage, 'current_lang_upper' => strtoupper($this->currentLanguage), 'current_page' => $this->sanitizePageParam($_GET['page'] ?? $this->getEffectiveDefaultPage()), 'available_langs' => array_map(function($lang) { $lang['is_current'] = $lang['code'] === $this->currentLanguage; $page = $_GET['page'] ?? $this->getEffectiveDefaultPage(); // Sanitize page parameter to prevent XSS $page = $this->sanitizePageParam($page); $lang['url'] = '/' . $lang['code'] . ($page !== $this->getEffectiveDefaultPage() ? '/' . $page : ''); return $lang; }, $this->getAvailableLanguages()), // Translations 't_home' => $this->t('home'), 't_search' => $this->t('search'), 't_search_placeholder' => $this->t('search_placeholder'), 't_search_button' => $this->t('search_button'), 't_welcome' => $this->t('welcome'), 't_created' => $this->t('created'), 't_modified' => $this->t('modified'), 't_author' => $this->t('author'), 't_manual' => $this->t('manual'), 't_no_content' => $this->t('no_content'), 't_no_results' => $this->t('no_results'), 't_results_found' => $this->t('results_found'), 't_breadcrumb_home' => $this->t('breadcrumb_home'), 't_file_details' => $this->t('file_details'), 't_guide' => $this->t('guide'), 't_powered_by' => $this->t('powered_by'), 't_directory_empty' => $this->t('directory_empty'), 't_page_not_found' => $this->t('page_not_found'), 't_page_not_found_text' => $this->t('page_not_found_text'), 't_mappen' => $this->t('mappen'), 't_paginas' => $this->t('paginas'), 't_author_website' => $this->t('author_website'), 't_author_git' => $this->t('author_git') ]; // File info for footer if (isset($page['file_info'])) { $templateData['created'] = htmlspecialchars($page['file_info']['created']); $templateData['modified'] = htmlspecialchars($page['file_info']['modified']); $templateData['show_created'] = !empty($page['file_info']['show_created']); $templateData['file_info_block'] = true; } else { $templateData['created'] = ''; $templateData['modified'] = ''; $templateData['show_created'] = false; $templateData['file_info_block'] = false; } // Check if content exists for guide link $hasContent = !$this->isContentDirEmpty(); $templateData['has_content'] = $hasContent; // Don't show site title link on guide page $templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']); // Pass guide-specific data to template if (isset($page['guide_breadcrumbs'])) { $templateData['guide_breadcrumbs'] = $page['guide_breadcrumbs']; $templateData['guide_lang'] = $page['guide_lang'] ?? $this->currentLanguage; $templateData['guide_page'] = $page['guide_page'] ?? ''; } // Map legacy frontmatter layout values to theme template keys $layoutKey = $this->mapLayoutToThemeKey($layout); // Add theme asset URLs to template data $themeManager = new ThemeManager($this->config); $templateData['theme_title'] = $themeManager->getTitle(); $templateData['theme_css_url'] = $themeManager->getCssUrl(); $templateData['theme_js_url'] = $themeManager->getJsUrl(); $templateData['theme_config'] = $themeManager->getConfig(); $templateData['theme_base_url'] = '/themes/' . basename($themeManager->getThemeDir()); $templateData['theme_css_files'] = $themeManager->getCssFiles(); $templateData['theme_js_files'] = $themeManager->getJsFiles(); $templateData['theme_favicon'] = $themeManager->getFaviconUrl(); // Plugin CSS (loaded after theme CSS so theme can override) $templateData['plugin_css_urls'] = $this->pluginManager->getPluginCssUrls(); // Render the page through the active theme $renderedLayout = $themeManager->render($layoutKey, $templateData); echo $renderedLayout; $this->pluginManager->doAction('onAfterRender', $renderedLayout); } /** * Generate breadcrumb navigation HTML * * @param bool $hasSidebar Whether sidebar content exists and should show toggle * @param array|null $guidePage Guide page data (when on a guide page) * @return string Breadcrumb HTML */ public function generateBreadcrumb($hasSidebar = true, $guidePage = null) { // Sidebar toggle button (shown before home icon in breadcrumb) $sidebarToggle = ''; if ($hasSidebar) { $sidebarToggle = ''; } // Guide page breadcrumb: Home > Handleiding > [page parts] if ($guidePage !== null) { $breadcrumb = ''; return $breadcrumb; } if (isset($_GET['search'])) { return ''; } $page = $_GET['page'] ?? $this->getEffectiveDefaultPage(); $page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8'); $page = preg_replace('/\.[^.]+$/', '', $page); $isHomepage = ($page === $this->getEffectiveDefaultPage()); $homeUrl = '/' . $this->currentLanguage; $breadcrumb = ''; return $breadcrumb; } /** * Render menu HTML with dropdown support * * @param array $items Menu items to render * @param int $level Current nesting level * @return string Rendered menu HTML */ private function renderMenu($items, $level = 0) { $html = ''; foreach ($items as $item) { if ($item['type'] === 'folder') { $hasChildren = !empty($item['children']); if ($hasChildren) { $folderId = 'folder-' . str_replace('/', '-', $item['path']); $isExpanded = $this->folderContainsActivePage($item['children']); if ($level === 0) { // Root level folders - Bootstrap dropdown $html .= '