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 the language directory * * Each language is a subdirectory under language/ containing a site.php file. * * @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; } /** * Load site translations for specified language * * @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 []; } /** * Get native language name for language code * * @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); } /** * 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(); } // 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); // 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('search') . ' ' . $this->t('results_found') . ': "' . htmlspecialchars($query) . '"

'; if (empty($this->searchResults)) { $content .= '

' . $this->t('no_results') . '.

'; } else { $content .= '

' . count($this->searchResults) . ' ' . $this->t('results_found') . ':

'; foreach ($this->searchResults as $result) { $content .= '
'; $content .= '
'; $content .= '
' . htmlspecialchars($result['title']) . '
'; $content .= '

' . htmlspecialchars($result['path']) . '

'; $content .= '

' . htmlspecialchars($result['snippet']) . '

'; $content .= '
'; } } return [ 'title' => 'Search Results', 'content' => $content ]; } /** * Parse metadata from content * * @param string $content Raw content * @return array Parsed metadata and content without meta block */ private function parseMetadata($content) { $metadata = []; $contentWithoutMeta = $content; // Check for YAML frontmatter (--- at start and end) if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)$/s', $content, $matches)) { $metaContent = $matches[1]; $contentWithoutMeta = $matches[2]; // Parse YAML-like metadata $lines = explode("\n", $metaContent); foreach ($lines as $line) { if (strpos($line, ':') !== false) { list($key, $value) = explode(':', $line, 2); $key = trim($key); $value = trim($value, ' "\''); // Handle boolean values if ($value === 'true') $value = true; elseif ($value === 'false') $value = false; $metadata[$key] = $value; } } } return [ 'metadata' => $metadata, 'content' => $contentWithoutMeta ]; } /** * Get hosts that count as internal for external-link detection * * @return array List of host names */ private function getInternalHosts(): array { $hosts = []; if (!empty($_SERVER['HTTP_HOST'])) { $host = strtolower(preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST'])); if ($host !== '') { $hosts[] = $host; // Treat www and apex variants as the same site $hosts[] = str_starts_with($host, 'www.') ? substr($host, 4) : 'www.' . $host; } } $authorWebsite = $this->normalizeUrl($this->config['author']['website'] ?? ''); if ($authorWebsite !== '') { $authorHost = parse_url($authorWebsite, PHP_URL_HOST); if ($authorHost) { $hosts[] = strtolower($authorHost); } } return array_values(array_unique(array_filter($hosts))); } /** * Normalize a URL: if no scheme is present, prepend https://. * Handles hostnames stored without protocol (e.g. "noorlander.info"). * * @param string $url Raw URL or hostname * @return string Normalized absolute URL, or '' if empty */ private function normalizeUrl(string $url): string { $url = trim($url); if ($url === '') { return ''; } if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) { return 'https://' . $url; } return $url; } /** * Parse Markdown content to HTML using League CommonMark * * @param string $content Raw Markdown content * @param string $actualFilePath Path to the source file (used for display name fallback) * @return array Parsed content with title and body */ public function parseMarkdown($content, $actualFilePath = '') { // Parse metadata first $parsed = $this->parseMetadata($content); $metadata = $parsed['metadata']; $content = $parsed['content']; // Extract title from first H1 or metadata $title = $metadata['title'] ?? ''; if (empty($title) && preg_match('/^#\s+(.+)$/m', $content, $matches)) { $title = trim($matches[1]); } // Configure CommonMark environment (autoloader already loaded in bootstrap) $config = [ 'html_input' => 'strip', 'allow_unsafe_links' => false, 'max_nesting_level' => 100, 'heading_permalink' => [ 'symbol' => '', 'aria_hidden' => true, 'html_class' => 'heading-permalink', 'id_prefix' => '', 'fragment_prefix' => '', 'apply_id_to_heading' => true, 'insert' => 'after', 'min_heading_level' => 1, 'max_heading_level' => 6, 'title' => 'Permalink', ], 'external_link' => [ 'internal_hosts' => $this->getInternalHosts(), 'open_in_new_window' => true, 'html_class' => 'external-link', 'nofollow' => '', 'noopener' => 'external', 'noreferrer' => 'external', ], ]; // Create environment with extensions $environment = new \League\CommonMark\Environment\Environment($config); $environment->addExtension(new \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension()); $environment->addExtension(new \League\CommonMark\Extension\Autolink\AutolinkExtension()); $environment->addExtension(new \League\CommonMark\Extension\Strikethrough\StrikethroughExtension()); $environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension()); $environment->addExtension(new \League\CommonMark\Extension\TaskList\TaskListExtension()); $environment->addExtension(new \League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension()); $environment->addExtension(new \League\CommonMark\Extension\ExternalLink\ExternalLinkExtension()); // Handle custom image size syntax: ![alt](url){:width="300" height="200"} $imagePlaceholders = []; $content = preg_replace_callback('/!\[([^\]]*)\]\(([^)]+)\)\{:([^}]+)\}/', function ($m) use (&$imagePlaceholders) { $index = count($imagePlaceholders); $alt = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8'); $url = htmlspecialchars($m[2], ENT_QUOTES, 'UTF-8'); $attrs = trim($m[3]); $imagePlaceholders[$index] = '' . $alt . ''; return '@@IMG_' . $index . '@@'; }, $content); // Create converter $converter = new \League\CommonMark\MarkdownConverter($environment); // Convert to HTML $body = $converter->convert($content)->getContent(); // Restore custom image tags foreach ($imagePlaceholders as $index => $imgTag) { $body = str_replace('@@IMG_' . $index . '@@', $imgTag, $body); } // Extract clean filename for title (without language prefix and extension) $filename = basename($actualFilePath); $cleanName = $this->formatDisplayName($filename); // Auto-link page titles to existing content pages (but not in H1 tags) if ($this->config['features']['auto_link_pages'] ?? true) { $body = $this->autoLinkPageTitles($body, $cleanName); } // Convert relative internal links to clean URLs (skip already-prefixed URLs) $body = preg_replace('/href="\/blog\/([^"]+)"/', 'href="/' . $this->currentLanguage . '/blog/$1"', $body); $body = preg_replace('/href="\/(?!nl\/|en\/|nl"|en")([^"]+)"/', 'href="/' . $this->currentLanguage . '/$1"', $body); return [ 'title' => $title ?: $cleanName ?: 'Untitled', 'content' => $body, 'metadata' => $metadata, 'layout' => $metadata['layout'] ?? 'sidebar-content' ]; } /** * Auto-link page titles found in content * * @param string $content Content to process for auto-linking * @param string $excludeTitle Title to exclude from auto-linking (current page title) * @return string Content with auto-linked page titles */ private function autoLinkPageTitles($content, $excludeTitle = '') { $pages = $this->getAllPageTitles(); foreach ($pages as $pagePath => $pageTitle) { if (strtolower($pageTitle) === strtolower($excludeTitle)) { continue; } // Protect existing tags,

content, and markdown links before each replacement $placeholders = []; $protected = preg_replace_callback('/]*>.*?<\/a>|]*>.*?<\/h1>|\[([^\]]*)\]\(([^)]*)\)|^#{1,6}\s.*$/m', function($m) use (&$placeholders) { $key = '@@ALINK_' . count($placeholders) . '@@'; $placeholders[$key] = $m[0]; return $key; }, $content); $pattern = '/\b' . preg_quote($pageTitle, '/') . '\b/i'; $protected = preg_replace_callback($pattern, function($matches) use ($pageTitle, $pagePath) { return '' . $matches[0] . ''; }, $protected); // Restore placeholders $content = str_replace(array_keys($placeholders), array_values($placeholders), $protected); } return $content; } /** * Get all page titles from content directory * * @return array Associative array of page paths to titles */ public function getAllPageTitles() { $pages = []; $this->scanForPageTitles($this->config['content_dir'], '', $pages); return $pages; } /** * Recursively scan for page titles in directory * * @param string $dir Directory to scan * @param string $prefix Relative path prefix * @param array &$pages Reference to pages array to populate * @return void */ private function scanForPageTitles($dir, $prefix, &$pages) { if (!is_dir($dir)) return; $items = scandir($dir); sort($items); foreach ($items as $item) { if ($item[0] === '.') continue; $path = $dir . '/' . $item; $relativePath = $prefix ? $prefix . '/' . $item : $item; if (is_dir($path)) { $this->scanForPageTitles($path, $relativePath, $pages); } elseif (preg_match('/\.(md|php|html)$/', $item)) { $title = $this->extractPageTitle($path); if ($title && !empty(trim($title))) { $pagePath = preg_replace('/\.[^.]+$/', '', $relativePath); $pages[$pagePath] = $title; } else { // Fallback to clean filename if no title found in content $filename = basename($path, pathinfo($path, PATHINFO_EXTENSION)); $cleanName = $this->formatDisplayName($filename); $pagePath = preg_replace('/\.[^.]+$/', '', $relativePath); $pages[$pagePath] = $cleanName; } } } } /** * Format display name from filename * * @param string $filename Filename without extension * @return string Formatted display name */ private function formatDisplayName($filename) { $filename = (string)$filename; if ($filename === '') { return ''; } $hasLeadingDash = $filename[0] === '-'; if ($hasLeadingDash) { $filename = substr($filename, 1); } $availableLangs = array_keys($this->getAvailableLanguages()); if (!empty($availableLangs)) { $langPattern = '/^(' . implode('|', array_map('preg_quote', $availableLangs)) . ')\.(.+)$/'; if (preg_match($langPattern, $filename, $matches)) { $filename = $matches[2]; } } $filename = preg_replace('/\.(md|php|html)$/', '', $filename); $name = trim(str_replace(['-', '_'], ' ', $filename)); $name = ucwords(strtolower($name)); $specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT']; $lower = strtolower($name); $name = $specialCases[$lower] ?? str_ireplace(array_keys($specialCases), array_values($specialCases), $name); return ($hasLeadingDash ? '- ' : '') . $name; } /** * Extract page title from file content * * @param string $filePath Path to the file * @return string|null Extracted title or null if not found */ private function extractPageTitle($filePath) { $content = file_get_contents($filePath); $extension = pathinfo($filePath, PATHINFO_EXTENSION); if ($extension === 'md') { // Extract first H1 from Markdown if (preg_match('/^#\s+(.+)$/m', $content, $matches)) { return trim($matches[1]); } } elseif ($extension === 'php') { // Extract title from PHP file if (preg_match('/\$title\s*=\s*["\']([^"\']+)["\']/', $content, $matches)) { return trim($matches[1]); } } elseif ($extension === 'html') { // Extract title from HTML file if (preg_match('/(.*?)<\/title>/i', $content, $matches)) { return trim(strip_tags($matches[1])); } if (preg_match('/<h1[^>]*>(.*?)<\/h1>/i', $content, $matches)) { return trim(strip_tags($matches[1])); } } return null; } /** * Parse PHP file and capture output * * @param string $filePath Path to PHP file * @return array Parsed content with title and body */ private function parsePHP($filePath) { // Read file content first to extract metadata $fileContent = file_get_contents($filePath); $parsed = $this->parseMetadata($fileContent); $metadata = $parsed['metadata']; // Extract title from metadata or PHP variables $title = $metadata['title'] ?? ''; ob_start(); // Make API and metadata available to the included file $api = new ContentAPI($this); $pageMetadata = $metadata; include $filePath; $content = ob_get_clean(); // Remove any remaining metadata from PHP output $content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content); // Remove metadata from content if it was included $parsed = $this->parseMetadata($content); $content = $parsed['content']; // Extract filename for title $filename = basename($filePath); $cleanName = $this->formatDisplayName($filename); return [ 'title' => $title ?: $cleanName ?: 'Untitled', 'content' => $content, 'metadata' => $metadata, 'layout' => $metadata['layout'] ?? 'sidebar-content' ]; } /** * Parse HTML content and extract title * * @param string $content Raw HTML content * @return array Parsed content with title and body */ private function parseHTML($content, $actualFilePath = '') { // Parse metadata first $parsed = $this->parseMetadata($content); $metadata = $parsed['metadata']; $content = $parsed['content']; // Extract title from metadata or HTML tags $title = $metadata['title'] ?? ''; if (empty($title)) { if (preg_match('/<title>(.*?)<\/title>/i', $content, $matches)) { $title = trim(strip_tags($matches[1])); } elseif (preg_match('/<h1[^>]*>(.*?)<\/h1>/i', $content, $matches)) { $title = trim(strip_tags($matches[1])); } } // Extract filename for title $filename = basename($actualFilePath); $cleanName = $this->formatDisplayName($filename); return [ 'title' => $title ?: $cleanName ?: 'Untitled', 'content' => $content, 'metadata' => $metadata, 'layout' => $metadata['layout'] ?? 'sidebar-content' ]; } /** * Check if content directory is empty * * @return bool True if content directory is empty or doesn't exist */ public function isContentDirEmpty() { $contentDir = $this->config['content_dir']; if (!is_dir($contentDir)) { return true; } $files = scandir($contentDir); $files = array_diff($files, ['.', '..']); // Filter out hidden files (e.g. .gitkeep) — only count visible content $files = array_filter($files, function($f) { return $f[0] !== '.'; }); return empty($files); } /** * Get welcome page content for new installations (empty content directory) * * Shows a clear "new installation" message with next steps when the * content directory has no content files yet. * * @return array Welcome page data */ private function getWelcomePage() { $adminUrl = $this->buildAdminUrl(); $guideUrl = '/' . $this->currentLanguage . '/guide'; $content = '<div class="welcome-page">'; $content .= '<h1>' . htmlspecialchars($this->t('welcome_title')) . '</h1>'; $content .= '<p class="alert alert-info" role="alert">'; $content .= '<i class="bi bi-info-circle me-2" aria-hidden="true"></i>'; $content .= htmlspecialchars($this->t('welcome_intro')); $content .= '</p>'; $content .= '<h2>' . htmlspecialchars($this->t('welcome_next_steps')) . '</h2>'; $content .= '<ol class="list-group list-group-numbered mb-4">'; $content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_1')) . '</li>'; $content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_2')) . '</li>'; $content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_3')) . '</li>'; $content .= '</ol>'; $content .= '<div class="d-flex flex-column flex-md-row gap-2 mb-4">'; $content .= '<a href="' . htmlspecialchars($adminUrl) . '" class="btn btn-primary">'; $content .= '<i class="bi bi-gear me-1" aria-hidden="true"></i> '; $content .= htmlspecialchars($this->t('welcome_admin_link')); $content .= '</a>'; $content .= '<a href="' . htmlspecialchars($guideUrl) . '" class="btn btn-outline-secondary">'; $content .= '<i class="bi bi-book me-1" aria-hidden="true"></i> '; $content .= htmlspecialchars($this->t('welcome_guide_link')); $content .= '</a>'; $content .= '</div>'; $content .= '</div>'; return [ 'title' => $this->t('welcome_title'), 'content' => $content, 'layout' => 'full_content', 'metadata' => [], ]; } /** * Get guide page content based on user language * * @return array Guide page data */ private function getGuidePage() { $lang = $this->currentLanguage; $pagePath = $_GET['page'] ?? ''; // Special case: /nl/guide sets page='guide', but we want index if ($pagePath === 'guide') { $pagePath = ''; } $rootDir = dirname(__DIR__, 3); $guideDir = $rootDir . '/guide/' . $lang; // Get guide file if (empty($pagePath)) { $guideFile = $guideDir . '/index.md'; } else { $pagePath = trim($pagePath, '/'); $guideFile = $guideDir . '/' . $pagePath . '.md'; } // Fallback to English if (!file_exists($guideFile) && $lang !== 'en') { $guideFile = $rootDir . '/guide/en/' . (empty($pagePath) ? 'index.md' : $pagePath . '.md'); } // Handle 404 if (!file_exists($guideFile)) { http_response_code(404); return [ 'title' => 'Handleiding niet gevonden', 'content' => '<p>De gevraagde handleiding is niet gevonden.</p>', '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; } /** * 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 = '<h1>' . htmlspecialchars($title) . '</h1>'; $result = [ 'title' => $title, 'content' => $content ]; if (!is_dir($dirPath)) { return [ 'title' => $title, 'content' => $content . '<p>Directory not found.</p>' ]; } $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 .= '<div class="list-group">'; foreach ($allItems as $item) { $content .= '<a href="' . htmlspecialchars($item['url']) . '" class="list-group-item list-group-item-action d-flex align-items-center">'; $content .= '<i class="bi ' . $item['icon'] . ' me-3"></i>'; $content .= '<span>' . htmlspecialchars($item['name']) . '</span>'; $content .= '</a>'; } $content .= '</div>'; $hasContent = true; } if (!$hasContent) { $content .= '<p>' . $this->t('directory_empty') . '.</p>'; } // 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() { $staticFile = __DIR__ . '/../../../admin/static/404.html'; $body = file_exists($staticFile) ? file_get_contents($staticFile) : '<h1>404 - ' . $this->t('page_not_found') . '</h1><p>' . $this->t('page_not_found_text') . '</p>'; return [ 'title' => $this->t('page_not_found'), 'content' => $body, 'layout' => 'full_content', 'metadata' => [], 'is_404' => true, ]; } /** * 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'); // 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'] ?? '', '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 = '<li class="breadcrumb-item sidebar-toggle-item"><button type="button" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle Sidebar" aria-label="Toggle Sidebar" aria-expanded="true"><i class="bi bi-layout-sidebar-inset"></i></button></li>'; } // Guide page breadcrumb: Home > Handleiding > [page parts] if ($guidePage !== null) { $breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">'; $breadcrumb .= $sidebarToggle; $breadcrumb .= '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>'; $breadcrumb .= '<li class="breadcrumb-item"> > </li>'; $guidePagePath = $guidePage['guide_page'] ?? ''; $guideLang = $guidePage['guide_lang'] ?? $this->currentLanguage; $guideBase = '/' . $guideLang . '/guide'; if (empty($guidePagePath)) { $breadcrumb .= '<li class="breadcrumb-item active">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</li>'; } else { $breadcrumb .= '<li class="breadcrumb-item"><a href="' . htmlspecialchars($guideBase, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</a></li>'; $parts = explode('/', $guidePagePath); $buildPath = ''; foreach ($parts as $i => $part) { $buildPath .= ($buildPath ? '/' : '') . $part; $title = htmlspecialchars(str_replace('-', ' ', ucfirst($part)), ENT_QUOTES, 'UTF-8'); $url = htmlspecialchars($guideBase . '?page=' . $buildPath, ENT_QUOTES, 'UTF-8'); if ($i === count($parts) - 1) { $breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>'; } else { $breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="' . $url . '">' . $title . '</a></li>'; } } } $breadcrumb .= '</ol></nav>'; return $breadcrumb; } if (isset($_GET['search'])) { return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li><li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $this->t('search') . '</li></ol></nav>'; } $page = $_GET['page'] ?? $this->getEffectiveDefaultPage(); $page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8'); $page = preg_replace('/\.[^.]+$/', '', $page); $isHomepage = ($page === $this->getEffectiveDefaultPage()); $homeUrl = '/' . $this->currentLanguage; $breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">'; $breadcrumb .= $sidebarToggle; // Home icon: clickable link to language root (always, unless we're on the root itself) $breadcrumb .= '<li class="breadcrumb-item"><a href="' . $homeUrl . '"><i class="bi bi-house"></i></a></li>'; // Split page path and build breadcrumb items (handles subdirectories dynamically) $parts = explode('/', $page); $currentPath = ''; foreach ($parts as $i => $part) { $currentPath .= ($currentPath ? '/' : '') . $part; $title = htmlspecialchars(ucfirst($part), ENT_QUOTES, 'UTF-8'); $safePath = htmlspecialchars($currentPath, ENT_QUOTES, 'UTF-8'); if ($i === count($parts) - 1) { // Last part - active page $breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>'; } else { // Parent directory - clickable link with separator $breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '/' . $safePath . '">' . $title . '</a></li>'; } } $breadcrumb .= '</ol></nav>'; 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 .= '<li class="nav-item dropdown">'; $html .= '<a class="nav-link dropdown-toggle" href="#" id="' . $folderId . '" role="button" data-bs-toggle="dropdown" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">'; $html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-down"></i>'; $html .= '</a>'; $html .= '<ul class="dropdown-menu" aria-labelledby="' . $folderId . '">'; $html .= $this->renderMenu($item['children'], $level + 1); $html .= '</ul>'; $html .= '</li>'; } else { // Nested folders - CSS hover submenu (unlimited depth) $html .= '<li class="dropdown-submenu' . ($isExpanded ? ' show' : '') . '">'; $html .= '<a class="dropdown-item dropdown-toggle" href="#" id="' . $folderId . '" role="button" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">'; $html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-right"></i>'; $html .= '</a>'; $html .= '<ul class="dropdown-menu' . ($isExpanded ? ' show' : '') . '" aria-labelledby="' . $folderId . '">'; $html .= $this->renderMenu($item['children'], $level + 1); $html .= '</ul>'; $html .= '</li>'; } } else { if ($level === 0) { $html .= '<li class="nav-item">'; $html .= '<span class="nav-link text-muted">' . htmlspecialchars($item['title']) . '</span>'; $html .= '</li>'; } else { $html .= '<li><span class="dropdown-item text-muted">' . htmlspecialchars($item['title']) . '</span></li>'; } } } else { // Show files in root as tabs, files in folders as dropdown items if ($level === 0) { $active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : ''; $html .= '<li class="nav-item">'; $html .= '<a class="nav-link ' . $active . '" href="' . htmlspecialchars($item['url']) . '">'; $html .= htmlspecialchars($item['title']); $html .= '</a>'; $html .= '</li>'; } else { // Show files in dropdown menus $active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : ''; $html .= '<li><a class="dropdown-item ' . $active . '" href="' . htmlspecialchars($item['url']) . '">'; $html .= htmlspecialchars($item['title']); $html .= '</a></li>'; } } } return $html; } /** * Map a frontmatter layout value to a theme template key. * * Legacy values are translated to the new theme keys. Unknown values * are passed through so ThemeManager can fall back to default_layout. * * @param string $layout Layout value from page metadata * @return string Theme template key */ private function mapLayoutToThemeKey(string $layout): string { return match ($layout) { 'content' => 'full_content', 'sidebar-content', 'content-sidebar' => 'left_sidebar', 'content-sidebar-reverse' => 'right_sidebar', 'sidebar' => 'custom1', default => $layout, }; } /** * Auto-detect the first available content page * * Scans the content directory for the first .md/.php/.html file * (preferring non-language-prefixed files) and returns its page key. * * @return string Detected page key, or 'index' as fallback */ private function detectDefaultPage(): string { $contentDir = $this->config['content_dir']; if (!is_dir($contentDir)) return 'index'; $files = scandir($contentDir); $candidates = []; foreach ($files as $f) { if (preg_match('/^((?:nl|en)\.)?(.+?)\.(md|php|html)$/', $f, $m)) { $candidates[] = ['prefix' => $m[1], 'name' => $m[2], 'file' => $f]; } } if (empty($candidates)) return 'index'; // Prefer non-language-prefixed files (e.g. index.md over nl.index.md) foreach ($candidates as $c) { if (empty($c['prefix'])) return $c['name']; } return $candidates[0]['name']; } /** * Detect the newest modified page (last modified timestamp) * * @return string Page key that was most recently modified or created */ private function detectNewestPage(): string { $contentDir = $this->config['content_dir']; $realContentDir = realpath($contentDir); if (!$realContentDir || !is_dir($realContentDir)) return 'index'; $availableLangs = array_keys($this->getAvailableLanguages()); if (empty($availableLangs)) { $availableLangs = ['nl', 'en']; } $langRegex = implode('|', array_map('preg_quote', $availableLangs)); $candidates = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($realContentDir, RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $fileInfo) { if (!$fileInfo->isFile()) continue; $ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, ['md', 'php', 'html'])) continue; $filePath = $fileInfo->getRealPath(); $relative = substr($filePath, strlen($realContentDir) + 1); // Skip hidden files/directories (starting with . or -) $parts = explode('/', str_replace('\\', '/', $relative)); $skip = false; foreach ($parts as $p) { if ($p !== '' && ($p[0] === '.' || $p[0] === '-')) { $skip = true; break; } } if ($skip) continue; // Strip extension $base = preg_replace('/\.(md|php|html)$/i', '', $relative); // Handle language prefix e.g. nl.test -> test, or dir/en.page -> dir/page $filename = basename($base); $dirPrefix = dirname($base); $dirPrefix = ($dirPrefix === '.' || $dirPrefix === '') ? '' : $dirPrefix . '/'; if (preg_match('/^(' . $langRegex . ')\.(.+)$/i', $filename, $m)) { $pageKey = $dirPrefix . $m[2]; } else { $pageKey = $base; } // If it ends with /index, e.g. folder/index -> folder if (str_ends_with($pageKey, '/index')) { $pageKey = substr($pageKey, 0, -6); } $mtime = $fileInfo->getMTime(); $candidates[] = [ 'key' => $pageKey ?: 'index', 'mtime' => $mtime ]; } if (empty($candidates)) return 'index'; usort($candidates, function ($a, $b) { return $b['mtime'] <=> $a['mtime']; }); return $candidates[0]['key']; } /** * Get homepage title * * @return string Homepage title */ private function getHomepageTitle() { // Use a generic "Home" label instead of the page name // to avoid duplication with the navigation menu return $this->t('home'); } /** * Check if folder contains the currently active page * * @param array $children Array of child items * @return bool True if active page is found in children */ private function folderContainsActivePage($children) { foreach ($children as $child) { if ($child['type'] === 'folder') { if (!empty($child['children']) && $this->folderContainsActivePage($child['children'])) { return true; } } else { if (isset($_GET['page']) && $_GET['page'] === $child['path']) { return true; } } } return false; } private function processContent(string $content): string { return str_replace('-/assets/', '/-assets/', $content); } }