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'] ?? []; $siteDefaultLang = $this->config['language']['default'] ?? 'nl'; $this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins, $siteDefaultLang); $api = new CMSAPI($this); $api->setPluginManager($this->pluginManager); $this->pluginManager->setAPI($api); $this->buildMenu(); if (isset($_GET['search'])) { $this->performSearch($_GET['search']); } } /** * Bouw een clean URL voor een pagina. * * @since 2.6.4 * * @param string|null $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 de pagina-parameter om XSS-aanvallen te voorkomen. * * Verwijdert alle tekens die niet alfanumeriek, streepje, underscore of * slash zijn. De punt blijft behouden zodat content-bestandsextensies * (md/php/html) in de URL overleven. * * @since 2.6.4 * * @param string $page Page parameter. * @return string Gesanitizeerde pagina-parameter of 'invalid-page'. */ private function sanitizePageParam(string $page): string { // Remove any characters that could be used for XSS; keep the dot so // content file extensions (md/php/html) survive in the URL. $sanitized = preg_replace('/[^a-zA-Z0-9_\-\/.]/', '', $page); return $sanitized ?: 'invalid-page'; } /** * Bepaal de effectieve standaardpagina (behandelt 'auto' en 'newest' modus). * * Het resultaat wordt gecached in $effectiveDefaultPage. * * @since 2.6.4 * * @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; } /** * Bouw een clean admin URL. * * @since 2.6.4 * * @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; } /** * Bouw een URL-string voor gebruik in templates (niet-statisch, met huidige taal-context). * * @since 2.6.4 * * @param string $page Pagina-pad. * @param array $extraParams Extra query parameters. * @return string Clean URL. */ public function url($page = 'index', $extraParams = []) { return $this->buildUrl($page, $this->currentLanguage, $extraParams); } /** * Bepaal de actieve taal vanuit het verzoek of de configuratie. * * @since 2.6.4 * * @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'); } /** * Verzamel alle beschikbare talen vanuit de language-directory. * * Elke taal is een subdirectory onder language/ met een site.php-bestand. * * @since 2.6.4 * * @return array Available languages with their codes and names. */ public function getAvailableLanguages() { $langDir = __DIR__ . '/../../../language/'; $languages = []; if (!is_dir($langDir)) { return $languages; } $entries = scandir($langDir); foreach ($entries as $entry) { if (preg_match('/^[a-z]{2}$/', $entry) && is_dir($langDir . $entry)) { $langCode = $entry; $siteFile = $langDir . $entry . '/site.php'; if (file_exists($siteFile)) { $translations = include $siteFile; if (!is_array($translations)) { continue; } $languages[$langCode] = [ 'code' => $langCode, 'name' => $translations['site_title'] ?? strtoupper($langCode), 'native_name' => $this->getNativeLanguageName($langCode) ]; } } } return $languages; } /** * Laad site-vertalingen voor de opgegeven taal. * * Valt terug op de standaardtaal en daarna op een lege array. * * @since 2.6.4 * * @param string $lang Language code. * @return array Translations array. */ private function loadTranslations($lang) { $langDir = __DIR__ . '/../../../language/'; $langFile = $langDir . $lang . '/site.php'; if (file_exists($langFile)) { $translations = include $langFile; if (is_array($translations)) { return $translations; } } // Fallback to default language $defaultLang = $this->config['language']['default'] ?? 'nl'; $defaultLangFile = $langDir . $defaultLang . '/site.php'; if (file_exists($defaultLangFile)) { $translations = include $defaultLangFile; if (is_array($translations)) { return $translations; } } // Return empty array if no translation found return []; } /** * Geef de native taalnaam voor een taalcode terug. * * Bevat een statische mapping van ISO-codes naar native taalnamen. * * @since 2.6.4 * * @param string $langCode Language code. * @return string Native language name. */ private function getNativeLanguageName($langCode) { $names = [ 'af' => 'Afrikaans', 'am' => 'አማርኛ', 'ar' => 'العربية', 'az' => 'Azərbaycan', 'bg' => 'Български', 'bn' => 'বাংলা', 'bs' => 'Bosanski', 'ca' => 'Català', 'cs' => 'Čeština', 'da' => 'Dansk', 'de' => 'Deutsch', 'el' => 'Ελληνικά', 'en' => 'English', 'es' => 'Español', 'et' => 'Eesti', 'eu' => 'Euskara', 'fa' => 'فارسی', 'fi' => 'Suomi', 'fil' => 'Filipino', 'fr' => 'Français', 'ga' => 'Gaeilge', 'gl' => 'Galego', 'gu' => 'ગુજરાતી', 'he' => 'עברית', 'hi' => 'हिन्दी', 'hr' => 'Hrvatski', 'hu' => 'Magyar', 'hy' => 'Հայերեն', 'id' => 'Bahasa Indonesia', 'is' => 'Íslenska', 'it' => 'Italiano', 'ja' => '日本語', 'ka' => 'ქართული', 'kk' => 'Қазақша', 'km' => 'ខ្មែរ', 'ko' => '한국어', 'lo' => 'ລາວ', 'lt' => 'Lietuvių', 'lv' => 'Latviešu', 'mk' => 'Македонски', 'mn' => 'Монгол', 'mr' => 'मराठी', 'ms' => 'Bahasa Melayu', 'mt' => 'Malti', 'my' => 'မြန်မာ', 'ne' => 'नेपाली', 'nl' => 'Nederlands', 'no' => 'Norsk', 'pa' => 'ਪੰਜਾਬੀ', 'pl' => 'Polski', 'pt' => 'Português', 'ro' => 'Română', 'ru' => 'Русский', 'si' => 'සිංහල', 'sk' => 'Slovenčina', 'sl' => 'Slovenščina', 'sq' => 'Shqip', 'sr' => 'Српски', 'sv' => 'Svenska', 'sw' => 'Kiswahili', 'ta' => 'தமிழ்', 'te' => 'తెలుగు', 'th' => 'ไทย', 'tl' => 'Tagalog', 'tr' => 'Türkçe', 'uk' => 'Українська', 'ur' => 'اردو', 'uz' => 'Oʻzbek', 'vi' => 'Tiếng Việt', 'zh' => '中文', ]; return $names[$langCode] ?? strtoupper($langCode); } /** * Geef vertaalde tekst voor een sleutel terug. * * @since 2.6.4 * * @param string $key Translation key. * @return string Translated text, of de sleutel zelf indien geen vertaling gevonden. */ public function t($key) { return $this->translations[$key] ?? $key; } /** * Bouw de menu-structuur op vanuit de content-directory. * * Roept de plugin-actie 'onMenuBuild' aan na het scannen. * * @since 2.6.4 * @return void */ private function buildMenu() { $this->menu = $this->scanDirectory($this->config['content_dir'], ''); $this->pluginManager->doAction('onMenuBuild', $this->menu); } /** * Scan recursief een directory voor content-bestanden en mappen. * * @since 2.6.4 * * @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) { // Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets) 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); // Keep the extension in the URL so files sharing the same name // but different type each keep their own viewable link. $result[] = [ 'type' => 'file', 'title' => $title, 'path' => $relativePath, 'url' => $this->buildUrl($relativePath) ]; } } return $result; } /** * Voer een zoekopdracht uit over alle content-bestanden. * * Reset de zoekresultaten, doorzoekt de content-directory en vuurt de * plugin-actie 'onSearch' af. * * @since 2.6.4 * * @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); } /** * Doorzoek recursief de bestanden in een directory naar de query. * * @since 2.6.4 * * @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) { // Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets) if ($item[0] === '.' || $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) ]; } } } } /** * Maak een zoeksnippet met de gevonden query. * * @since 2.6.4 * * @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 . '...'; } /** * Bepaal de content van de huidige pagina op basis van het verzoek. * * Behandelt zoek-, guide-, welcome- en 404-verzoeken, path-traversal- * validatie, directory-listings en taal-specifieke bestandsversies. * * @since 2.6.4 * @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(); } // Determine if the request has a valid language prefix $availableLangs = array_keys($this->getAvailableLanguages()); $requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/'; $requestPath = ltrim($requestPath, '/'); $langFromUrl = ''; if ($requestPath !== '' && in_array(explode('/', $requestPath)[0], $availableLangs, true)) { $langFromUrl = explode('/', $requestPath)[0]; } // No language prefix and not the root → 404 if ($langFromUrl === '' && $requestPath !== '' && $requestPath !== 'favicon.ico') { return $this->getError404(); } // Check if content directory is empty — show welcome page for new installations if ($this->isContentDirEmpty()) { $requestedPage = $_GET['page'] ?? ''; // Only show welcome page on the home URL; everything else is a 404 if (empty($requestedPage) || $requestedPage === $this->getEffectiveDefaultPage()) { return $this->getWelcomePage(); } return $this->getError404(); } $page = $_GET['page'] ?? $this->getEffectiveDefaultPage(); // Limit length $page = substr($page, 0, 255); // Detect explicit extension from the URL (e.g. /nl/test.php → use test.php) $explicitExt = null; if (preg_match('/\.(md|php|html)$/i', $page, $extMatch)) { $explicitExt = strtolower($extMatch[1]); } // Strip any content extension from the URL for the base path $pageWithoutExt = preg_replace('/\.(md|php|html)$/i', '', $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; // If the URL has an explicit extension, try that exact file first if ($explicitExt !== null && file_exists($filePath . '.' . $explicitExt)) { $actualFilePath = $filePath . '.' . $explicitExt; $result = $this->resolveContentByType($filePath, $explicitExt); if ($result !== null) { $actualFilePath = $result['path']; $result = $result['content']; } } // No explicit extension: serve the first matching file (md > php > html) if (!isset($result)) { $resolved = $this->resolveContentByType($filePath); if ($resolved !== null) { $actualFilePath = $resolved['path']; $result = $resolved['content']; } } // If no exact match found, check for language-specific versions if (!isset($result)) { $langPrefix = $this->currentLanguage; $langBase = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt; $resolved = $this->resolveContentByType($langBase, $explicitExt); if ($resolved !== null) { $actualFilePath = $resolved['path']; $result = $resolved['content']; } } // 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(); } /** * Bepaal het eerste bestaande content-bestand voor een base-path op type-prioriteit. * * Een verzoek zonder expliciete extensie (bijv. "test") serveert het * eerste bestaande bestand uit test.md -> test.php -> test.html. Wanneer * $preferredExt is opgegeven (de URL bevatte een extensie) wordt dat * type eerst geprobeerd, zodat /nl/test.php test.php serveert ook als * test.md bestaat. * * @since 2.6.4 * * @param string $basePath Absolute path without extension. * @param string|null $preferredExt Optional extension to try first (md|php|html). * @return array|null ['path' => string, 'content' => array] or null. */ private function resolveContentByType(string $basePath, ?string $preferredExt = null): ?array { $order = ['md', 'php', 'html']; if ($preferredExt !== null && in_array($preferredExt, $order, true)) { $order = array_unique(array_merge([$preferredExt], $order)); } foreach ($order as $ext) { $candidate = $basePath . '.' . $ext; if (file_exists($candidate)) { if ($ext === 'md') { $content = $this->parseMarkdown(file_get_contents($candidate), $candidate); } elseif ($ext === 'php') { $content = $this->parsePHP($candidate); } else { $content = $this->parseHTML(file_get_contents($candidate)); } return ['path' => $candidate, 'content' => $content]; } } return null; } /** * Verzamel bestandsinformatie inclusief aanmaak- en wijzigingsdatums. * * @since 2.6.4 * * @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 een bestandsgrootte in leesbare vorm (B, KB, MB, GB). * * @since 2.6.4 * * @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]; } /** * Bouw de content voor de zoekresultatenpagina. * * @since 2.6.4 * @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 .= ''; $content .= ''; $content .= htmlspecialchars($this->t('welcome_intro')); $content .= '
'; $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; } /** * Genereer een directory-listingpagina. * * Toont de titel van de map en een lijst met alle mappen en bestanden * daarin. Leest eventuele layout/plugins-metadata uit een index.md in * dezelfde map. * * @since 2.6.4 * * @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) { // Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets) if ($item[0] === '.' || $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)) { // Each file keeps its own link (incl. extension) so every type // stays viewable at the frontend even when names overlap. $extractedTitle = $this->extractPageTitle($itemPath); $fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME)); $icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' : (pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark'); $allItems[] = [ 'name' => $fileTitle, 'path' => $relativePath, 'url' => $this->buildUrl($relativePath), '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, ]; } /** * Geef de content van de 404-foutpagina. * * @since 2.6.4 * @return array 404 page data. */ private function getError404() { $staticFile = __DIR__ . '/../../../admin/static/404.html'; $body = file_exists($staticFile) ? file_get_contents($staticFile) : '' . $this->t('page_not_found_text') . '
'; return [ 'title' => $this->t('page_not_found'), 'content' => $body, 'layout' => 'full_content', 'metadata' => [], 'is_404' => true, ]; } /** * Geef de opgebouwde menu-structuur terug. * * @since 2.6.4 * @return array Menu structure for navigation. */ public function getMenu() { return $this->menu; } /** * Render de volledige pagina met het actieve theme. * * Bepaalt pagina-data, vuurt plugin-acties af, stelt template-data samen * (menu, sidebar, breadcrumbs, SEO, theme-assets) en echoot de * gerenderde layout via ThemeManager. * * @since 2.6.4 * @return void */ public function render() { $page = $this->getPage(); $this->pluginManager->doAction('onPageLoad', $page); $this->pluginManager->doAction('onBeforeRender'); // Set 404 status for not-found pages (rendered through the theme) if (!empty($page['is_404'])) { http_response_code(404); } $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' => $this->config['author']['git'] ?? '', // Per-page author metadata (from frontmatter, if present) 'page_author_name' => htmlspecialchars($page['metadata']['author_name'] ?? ''), 'page_author_email' => htmlspecialchars($page['metadata']['author_email'] ?? ''), 'page_created' => htmlspecialchars($page['metadata']['created'] ?? ''), '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); } /** * Genereer breadcrumb-navigatie-HTML. * * Ondersteunt guide-pagina's, zoekpagina's en reguliere content-pagina's * met subdirectory's. Toont optioneel een sidebar-toggle-knop. * * @since 2.6.4 * * @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 met ondersteuning voor dropdowns. * * Bestanden op root-niveau worden als tabs getoond; bestanden in mappen * als dropdown-items. Mappen zonder kinderen worden als uitgevinkt getoond. * * @since 2.6.4 * * @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 .= '