CodePress/engine/core/class/CodePressCMS.php
Edwin Noorlander 561832161e Fix title extraction to always use filename/directory name instead of content
- Remove H1 and HTML title extraction from parse methods
- Always use formatDisplayName() for consistent filename-based titles
- Add file path parameters to parseMarkdown() and parseHTML()
- Fix directory precedence to check directories before files
- Update AGENTS.md with title vs filename clarification
- Remove debug code from templates and methods

Resolves: Page titles now consistently show file/directory names
without language prefixes and extensions, never content titles.
2025-11-22 18:00:35 +01:00

1138 lines
44 KiB
PHP

<?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 {
private $config;
private $menu = [];
private $searchResults = [];
private $currentLanguage;
private $translations = [];
/**
* Constructor - Initialize the CMS with configuration
*
* @param array $config Configuration array containing site settings
*/
public function __construct($config) {
$this->config = $config;
$this->currentLanguage = $this->getCurrentLanguage();
$this->translations = $this->loadTranslations($this->currentLanguage);
$this->buildMenu();
if (isset($_GET['search'])) {
$this->performSearch($_GET['search']);
}
}
/**
* Get current language from URL or use default
*
* @return string Current language code
*/
private function getCurrentLanguage() {
return $_GET['lang'] ?? $this->config['language']['default'] ?? 'nl';
}
/**
* Load translations for specified language
*
* @param string $lang Language code
* @return array Translations array
*/
private function loadTranslations($lang) {
$langFile = __DIR__ . '/../../lang/' . $lang . '.php';
error_log("Loading language file: " . $langFile);
if (file_exists($langFile)) {
$translations = include $langFile;
error_log("Loaded translations for " . $lang . ": " . print_r($translations, true));
return $translations;
}
// Fallback to default language
$defaultLang = $this->config['language']['default'] ?? 'nl';
$defaultLangFile = __DIR__ . '/../../lang/' . $defaultLang . '.php';
error_log("Fallback to default language: " . $defaultLangFile);
return file_exists($defaultLangFile) ? include $defaultLangFile : [];
}
/**
* 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'], '');
}
/**
* 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] === '.') continue;
// Skip language-specific content that doesn't match current language
if (preg_match('/^(nl|en)\./', $item)) {
$langPrefix = substr($item, 0, 2);
if (($langPrefix === 'nl' && $this->currentLanguage !== 'nl') ||
($langPrefix === 'en' && $this->currentLanguage !== 'en')) {
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' => '?page=' . $pathWithoutExt . '&lang=' . $this->currentLanguage
];
}
}
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);
}
/**
* 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' => '?page=' . $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
if (isset($_GET['guide'])) {
return $this->getGuidePage();
}
// Check if content directory is empty
if ($this->isContentDirEmpty()) {
return $this->getGuidePage();
}
$page = $_GET['page'] ?? $this->config['default_page'];
// Only remove file extension at the end, not all dots
$pageWithoutExt = preg_replace('/\.(md|php|html)$/', '', $page);
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
// Check if directory exists FIRST (directories take precedence over files)
if (is_dir($filePath)) {
return $this->getDirectoryListing($pageWithoutExt, $filePath);
}
$actualFilePath = null;
// Check if directory exists first (directories take precedence over files)
if (is_dir($filePath)) {
$directoryResult = $this->getDirectoryListing($pageWithoutExt, $filePath);
return $directoryResult;
}
// 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);
return $result;
}
return $this->getError404();
}
/**
* Get file information including creation and modification dates
*
* @param string $filePath Path to the file
* @return array|null File information or null if file doesn't exist
*/
private function getFileInfo($filePath) {
if (!file_exists($filePath)) {
return null;
}
$stats = stat($filePath);
$created = date('d-m-Y H:i', $stats['ctime']);
$modified = date('d-m-Y H:i', $stats['mtime']);
return [
'created' => $created,
'modified' => $modified,
'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 Markdown content to HTML using League CommonMark
*
* @param string $content Raw Markdown content
* @return array Parsed content with title and body
*/
private function parseMarkdown($content, $actualFilePath = '') {
// Extract title from first H1
$title = '';
if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
$title = trim($matches[1]);
}
// Include autoloader
require_once __DIR__ . '/../../../vendor/autoload.php';
// Configure CommonMark environment
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
'max_nesting_level' => 100,
];
// 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());
// Create converter
$converter = new \League\CommonMark\MarkdownConverter($environment);
// Convert to HTML
$body = $converter->convert($content)->getContent();
// 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)
$body = $this->autoLinkPageTitles($body, $cleanName);
// Convert relative internal links to CMS format
$body = preg_replace('/href="\/blog\/([^"]+)"/', 'href="?page=blog/$1"', $body);
$body = preg_replace('/href="\/([^"]+)"/', 'href="?page=$1"', $body);
return [
'title' => $cleanName ?: 'Untitled',
'content' => $body
];
}
/**
* 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 = '') {
// Get all available pages with their titles
$pages = $this->getAllPageTitles();
foreach ($pages as $pagePath => $pageTitle) {
// Create a pattern that matches the exact page title (case-insensitive)
// Use word boundaries and avoid H1 tags to prevent linking inside headings
$pattern = '/\b' . preg_quote($pageTitle, '/') . '\b(?!(?=<\/h1>))/i';
// Replace with link, but avoid linking inside existing links, headings, or markdown
$replacement = function($matches) use ($pageTitle, $pagePath, $excludeTitle) {
$text = $matches[0];
// Check if we're inside an existing link or markdown syntax, or if it's the current page title
if (preg_match('/\[.*?\]\(.*?\)/', $text) ||
preg_match('/\[.*?\]:/', $text) ||
preg_match('/<a[^>]*>/', $text) ||
preg_match('/href=/', $text) ||
preg_match('/<h1>/', $text) ||
strtolower($text) === strtolower($excludeTitle)) {
return $text; // Don't link existing links, current page title, or H1 headings
}
return '<a href="?page=' . $pagePath . '&lang=' . $this->currentLanguage . '" class="auto-link" title="Ga naar ' . htmlspecialchars($pageTitle) . '">' . $text . '</a>';
};
$content = preg_replace_callback($pattern, $replacement, $content);
}
return $content;
}
/**
* Get all page titles from content directory
*
* @return array Associative array of page paths to titles
*/
private function getAllPageTitles() {
$pages = [];
$this->scanForPageTitles($this->config['content_dir'], '', $pages);
return $pages;
}
/**
* Get all page names from content directory (for navigation)
*
* @return array Associative array of page paths to display names
*/
private function getAllPageNames() {
$pages = [];
$this->scanForPageNames($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;
}
}
}
}
/**
* Recursively scan for page names in directory (for navigation)
*
* @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 scanForPageNames($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->scanForPageNames($path, $relativePath, $pages);
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
// Use filename without extension as display name
$displayName = preg_replace('/\.[^.]+$/', '', $item);
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $this->formatDisplayName($displayName);
}
}
}
/**
* Format display name from filename
*
* @param string $filename Filename without extension
* @return string Formatted display name
*/
private function formatDisplayName($filename) {
// Debug: log input
error_log("DEBUG: formatDisplayName input: '$filename'");
// Remove language prefixes (nl. or en.) from display names
if (preg_match('/^(nl|en)\.(.+)$/', $filename, $matches)) {
$filename = $matches[2];
}
// Remove language prefixes from directory names (nl.php-testen -> php-testen)
if (preg_match('/^(nl|en)\.php-(.+)$/', $filename, $matches)) {
$filename = 'php-' . $matches[2];
}
// Remove file extensions (.md, .php, .html) from display names
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
// Handle special cases first (only for exact filenames, not directories)
// These should only apply to actual files, not directory names
if (strtolower($filename) === 'phpinfo' && !preg_match('/\//', $filename)) {
return 'phpinfo';
}
if (strtolower($filename) === 'ict' && !preg_match('/\//', $filename)) {
return 'ICT';
}
// Replace hyphens and underscores with spaces
$name = str_replace(['-', '_'], ' ', $filename);
// Convert to title case (first letter uppercase, rest lowercase)
$name = ucwords(strtolower($name));
// Handle other special cases
$name = str_replace('Phpinfo', 'phpinfo', $name);
$name = str_replace('Ict', 'ICT', $name);
return $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) {
ob_start();
include $filePath;
$content = ob_get_clean();
// Extract filename for title
$filename = basename($filePath);
$cleanName = $this->formatDisplayName($filename);
return [
'title' => $cleanName ?: 'Untitled',
'content' => $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 = '') {
// Extract filename for title
$filename = basename($actualFilePath);
$cleanName = $this->formatDisplayName($filename);
return [
'title' => $cleanName ?: 'Untitled',
'content' => $content
];
}
/**
* Check if content directory is empty
*
* @return bool True if content directory is empty or doesn't exist
*/
private 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;
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
if (!file_exists($guideFile)) {
$guideFile = __DIR__ . '/../../../guide/en.codepress.md'; // Fallback to English
}
$content = file_get_contents($guideFile);
$result = $this->parseMarkdown($content);
// Set special title for guide
$result['title'] = $this->t('manual') . ' - CodePress CMS';
return $result;
}
/**
* Detect user language from browser headers
*
* @return string Language code ('nl' or 'en')
*/
private function detectLanguage() {
// Simple language detection based on browser Accept-Language header
$acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
if (strpos($acceptLanguage, 'nl') !== false) {
return 'nl';
}
return 'en'; // Default to English
}
/**
* 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);
// Debug: log what we're working with
error_log("DEBUG: getDirectoryListing - dirName: '$dirName', formatDisplayName result: '" . $this->formatDisplayName($dirName) . "'");
$title = $this->formatDisplayName($dirName) ?: 'Home';
$content = '<h1>' . htmlspecialchars($title) . '</h1>';
$result = [
'title' => $title,
'content' => $content
];
// Debug: ensure we're returning the right title
error_log("DEBUG: getDirectoryListing returning title: '$title'");
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' => '?page=' . $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' => '?page=' . $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;
}
$content .= '</div>';
if (!$hasContent) {
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
}
return [
'title' => $title,
'content' => $content
];
}
/**
* 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();
$menu = $this->getMenu();
$breadcrumb = $this->generateBreadcrumb();
// Get homepage title
$homepageTitle = $this->getHomepageTitle();
// Prepare template data
$templateData = [
'site_title' => $this->config['site_title'],
'page_title' => htmlspecialchars($page['title']),
'content' => $page['content'],
'search_query' => isset($_GET['search']) ? htmlspecialchars($_GET['search']) : '',
'menu' => $this->renderMenu($menu),
'breadcrumb' => $breadcrumb,
'default_page' => $this->config['default_page'],
'homepage' => $this->config['default_page'],
'homepage_title' => $homepageTitle,
'is_homepage' => (!isset($_GET['page']) || $_GET['page'] === $this->config['default_page']),
'home_active_class' => (!isset($_GET['page']) || $_GET['page'] === $this->config['default_page']) ? 'active' : '',
'is_guide_page' => isset($_GET['guide']),
'lang_switch_url' => isset($_GET['guide']) ? '&guide' : '&page=' . $this->config['default_page'],
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $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',
// Theme colors
'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd',
'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff',
'navigation_color' => $this->config['theme']['navigation_color'] ?? '#f8f9fa',
'navigation_font_color' => $this->config['theme']['navigation_font_color'] ?? '#000000',
// Language
'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage),
'available_langs' => $this->config['language']['available'] ?? ['nl', 'en'],
// 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')
];
// File info for footer
if (isset($page['file_info'])) {
$templateData['file_info'] = $this->t('created') . ': ' . htmlspecialchars($page['file_info']['created']) .
' | ' . $this->t('modified') . ': ' . htmlspecialchars($page['file_info']['modified']);
$templateData['file_info_block'] = '<span class="file-details"> | ' . $templateData['file_info'] . '</span>';
} else {
$templateData['file_info'] = '';
$templateData['file_info_block'] = '';
}
// 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']);
// Load and render all templates with data
$layoutTemplate = file_get_contents($this->config['templates_dir'] . '/layout.mustache');
$headerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/header.mustache');
$navigationTemplate = file_get_contents($this->config['templates_dir'] . '/assets/navigation.mustache');
$footerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache');
// Determine content type and load appropriate template
$contentType = $this->getContentType($page);
$contentTemplateFile = $this->config['templates_dir'] . '/' . $contentType . '_content.mustache';
$contentTemplate = file_exists($contentTemplateFile) ? file_get_contents($contentTemplateFile) : '<div class="content">{{{content}}}</div>';
// Render all templates with data
$renderedHeader = SimpleTemplate::render($headerTemplate, $templateData);
$renderedNavigation = SimpleTemplate::render($navigationTemplate, $templateData);
$renderedFooter = SimpleTemplate::render($footerTemplate, $templateData);
$renderedContent = SimpleTemplate::render($contentTemplate, $templateData);
// Replace partials in layout
$finalTemplate = str_replace('{{>header}}', $renderedHeader, $layoutTemplate);
$finalTemplate = str_replace('{{>navigation}}', $renderedNavigation, $finalTemplate);
$finalTemplate = str_replace('{{>footer}}', $renderedFooter, $finalTemplate);
$finalTemplate = str_replace('{{>content_template}}', $renderedContent, $finalTemplate);
// Render the final layout with all template data
$renderedLayout = SimpleTemplate::render($finalTemplate, $templateData);
echo $renderedLayout;
}
/**
* Generate breadcrumb navigation HTML
*
* @return string Breadcrumb HTML
*/
private function generateBreadcrumb() {
if (isset($_GET['search'])) {
return '<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="?page=' . $this->config['default_page'] . '&lang=' . $this->currentLanguage . '"></a></li><li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $this->t('search') . '</li></ol></nav>';
}
$page = $_GET['page'] ?? $this->config['default_page'];
$page = preg_replace('/\.[^.]+$/', '', $page);
if ($page === $this->config['default_page']) {
return '<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item active"><i class="bi bi-house"></i></li></ol></nav>';
}
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
// Start with home icon linking to default page (root)
$breadcrumb .= '<li class="breadcrumb-item"><a href="?page=' . $this->config['default_page'] . '&lang=' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>';
// Split page path and build breadcrumb items
$parts = explode('/', $page);
$currentPath = '';
foreach ($parts as $i => $part) {
$currentPath .= ($currentPath ? '/' : '') . $part;
$title = ucfirst($part);
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="?page=' . $currentPath . '&lang=' . $this->currentLanguage . '">' . $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
$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 in dropdown
$html .= '<li class="dropdown-submenu">';
$html .= '<a class="dropdown-item dropdown-toggle" href="#" id="' . $folderId . '">';
$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 {
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) {
// Don't show the homepage file as a separate tab since it's already the Home button
if ($item['path'] === $this->config['default_page']) {
continue;
}
$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;
}
/**
* Determine content type for current page
*
* @param array $page Page data
* @return string Content type (markdown, php, html)
*/
private function getContentType($page) {
// Try to determine content type from page request
$pagePath = $_GET['page'] ?? $this->config['default_page'];
$pagePath = preg_replace('/\.[^.]+$/', '', $pagePath);
$filePath = $this->config['content_dir'] . '/' . $pagePath;
// Check for different file extensions
if (file_exists($filePath . '.md')) {
return 'markdown';
} elseif (file_exists($filePath . '.php')) {
return 'php';
} elseif (file_exists($filePath . '.html')) {
return 'html';
} elseif (file_exists($filePath)) {
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
return in_array($extension, ['md', 'php', 'html']) ? $extension : 'markdown';
}
// Default to markdown
return 'markdown';
}
/**
* Get homepage title
*
* @return string Homepage title
*/
private function getHomepageTitle() {
// Use formatted filename for homepage title in navigation
return $this->formatDisplayName($this->config['default_page']);
}
/**
* 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;
}
}