Files
CodePress/cms/core/class/CodePressCMS.php
T
E.Noorlander 94f9b3d831 Merge development v2.6.0 into main
Resolved conflicts by taking development (v2.6.0) version for all files.
Removed statistics.twig (replaced by Statistics plugin).
2026-08-15 19:32:47 +02:00

1719 lines
67 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* CodePressCMS - Lightweight file-based content management system
*
* Features:
* - Markdown, PHP, and HTML content support
* - Dynamic navigation with dropdown menus
* - Search functionality
* - Breadcrumb navigation
* - Auto-linking between pages
* - Bootstrap 5 styling
* - File-based organization
* - SEO friendly URLs
* - Responsive design
*
* @author Edwin Noorlander
* @version 1.0.0
* @license MIT
*/
class CodePressCMS {
public $config;
public $currentLanguage;
public $searchResults = [];
private $menu = [];
private ?string $effectiveDefaultPage = null;
private $translations = [];
private $pluginManager;
/**
* Constructor - Initialize the CMS with configuration
*
* @param array $config Configuration array containing site settings
*/
public function __construct($config) {
$this->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();
}
// 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 = '<h2>' . $this->t('search') . ' ' . $this->t('results_found') . ': "' . htmlspecialchars($query) . '"</h2>';
if (empty($this->searchResults)) {
$content .= '<p>' . $this->t('no_results') . '.</p>';
} else {
$content .= '<p>' . count($this->searchResults) . ' ' . $this->t('results_found') . ':</p>';
foreach ($this->searchResults as $result) {
$content .= '<div class="card mb-3">';
$content .= '<div class="card-body">';
$content .= '<h5 class="card-title"><a href="' . htmlspecialchars($result['url']) . '">' . htmlspecialchars($result['title']) . '</a></h5>';
$content .= '<p class="card-text text-muted">' . htmlspecialchars($result['path']) . '</p>';
$content .= '<p class="card-text">' . htmlspecialchars($result['snippet']) . '</p>';
$content .= '</div></div>';
}
}
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] = '<img src="' . $url . '" alt="' . $alt . '" ' . $attrs . '>';
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 <a> tags, <h1> content, and markdown links before each replacement
$placeholders = [];
$protected = preg_replace_callback('/<a\b[^>]*>.*?<\/a>|<h1\b[^>]*>.*?<\/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 '<a href="' . $this->buildUrl($pagePath) . '" class="auto-link" title="' . $this->t('go_to') . ' ' . htmlspecialchars($pageTitle) . '">' . $matches[0] . '</a>';
}, $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>(.*?)<\/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, ['.', '..']);
return empty($files);
}
/**
* 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() {
return [
'title' => $this->t('page_not_found'),
'content' => '<h1>404 - ' . $this->t('page_not_found') . '</h1><p>' . $this->t('page_not_found_text') . '</p>'
];
}
/**
* 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' => $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);
}
}