- Fix template variable replacement in guide pages by removing {{}} brackets
- Escape code blocks in guide markdown to prevent template processing
- Completely rewrite guide documentation with comprehensive CMS features
- Add bilingual guide support (English/Dutch) with detailed examples
- Enhance CodePressCMS core with improved guide page handling
- Update template system with better layout and footer components
- Improve language files with additional translations
- Update configuration with enhanced theme and language settings
Resolves issue where guide pages were showing replaced template variables
instead of displaying them as documentation examples.
1341 lines
52 KiB
PHP
1341 lines
52 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 {
|
|
public $config;
|
|
public $currentLanguage;
|
|
public $searchResults = [];
|
|
private $menu = [];
|
|
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
|
|
require_once __DIR__ . '/../plugin/PluginManager.php';
|
|
require_once __DIR__ . '/../plugin/CMSAPI.php';
|
|
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins');
|
|
$api = new CMSAPI($this);
|
|
$this->pluginManager->setAPI($api);
|
|
|
|
$this->buildMenu();
|
|
|
|
if (isset($_GET['search'])) {
|
|
$this->performSearch($_GET['search']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current language from request or config
|
|
*
|
|
* @return string Current language code
|
|
*/
|
|
private function getCurrentLanguage() {
|
|
$lang = $_GET['lang'] ?? $this->config['language']['default'] ?? 'nl';
|
|
// Validate language parameter to prevent XSS
|
|
$allowedLanguages = ['nl', 'en'];
|
|
return in_array($lang, $allowedLanguages) ? $lang : ($this->config['language']['default'] ?? 'nl');
|
|
}
|
|
|
|
/**
|
|
* Get all available languages from lang directory
|
|
*
|
|
* @return array Available languages with their codes and names
|
|
*/
|
|
public function getAvailableLanguages() {
|
|
$langDir = __DIR__ . '/../../lang/';
|
|
$languages = [];
|
|
|
|
if (!is_dir($langDir)) {
|
|
return $languages;
|
|
}
|
|
|
|
$files = scandir($langDir);
|
|
foreach ($files as $file) {
|
|
if (preg_match('/^([a-z]{2})\.php$/', $file, $matches)) {
|
|
$langCode = $matches[1];
|
|
$langFile = $langDir . $file;
|
|
|
|
if (file_exists($langFile)) {
|
|
$translations = include $langFile;
|
|
$languages[$langCode] = [
|
|
'code' => $langCode,
|
|
'name' => $translations['site_title'] ?? strtoupper($langCode),
|
|
'native_name' => $this->getNativeLanguageName($langCode)
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $languages;
|
|
}
|
|
|
|
/**
|
|
* Load translations for specified language
|
|
*
|
|
* @param string $lang Language code
|
|
* @return array Translations array
|
|
*/
|
|
private function loadTranslations($lang) {
|
|
$langFile = __DIR__ . '/../../lang/' . $lang . '.php';
|
|
if (file_exists($langFile)) {
|
|
$translations = include $langFile;
|
|
return $translations;
|
|
}
|
|
// Fallback to default language
|
|
$defaultLang = $this->config['language']['default'] ?? 'nl';
|
|
$defaultLangFile = __DIR__ . '/../../lang/' . $defaultLang . '.php';
|
|
if (file_exists($defaultLangFile)) {
|
|
return include $defaultLangFile;
|
|
}
|
|
// Return empty array if no translation found
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Get native language name for language code
|
|
*
|
|
* @param string $langCode Language code
|
|
* @return string Native language name
|
|
*/
|
|
private function getNativeLanguageName($langCode) {
|
|
$names = [
|
|
'nl' => 'Nederlands',
|
|
'en' => 'English',
|
|
'fr' => 'Français',
|
|
'de' => 'Deutsch',
|
|
'es' => 'Español',
|
|
'it' => 'Italiano',
|
|
'pt' => 'Português',
|
|
'ru' => 'Русский',
|
|
'zh' => '中文',
|
|
'ja' => '日本語',
|
|
'ar' => 'العربية'
|
|
];
|
|
|
|
return $names[$langCode] ?? strtoupper($langCode);
|
|
}
|
|
|
|
/**
|
|
* Get translated text
|
|
*
|
|
* @param string $key Translation key
|
|
* @return string Translated text
|
|
*/
|
|
public function t($key) {
|
|
return $this->translations[$key] ?? $key;
|
|
}
|
|
|
|
/**
|
|
* Build menu structure from content directory
|
|
*
|
|
* @return void
|
|
*/
|
|
private function buildMenu() {
|
|
$this->menu = $this->scanDirectory($this->config['content_dir'], '');
|
|
}
|
|
|
|
/**
|
|
* 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'];
|
|
// Sanitize page parameter to prevent XSS
|
|
$page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
|
|
// Prevent path traversal
|
|
$page = str_replace(['../', '..\\', '..'], '', $page);
|
|
// 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;
|
|
|
|
// 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 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
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse Markdown content to HTML using League CommonMark
|
|
*
|
|
* @param string $content Raw Markdown content
|
|
* @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]);
|
|
}
|
|
|
|
// 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' => $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 = '') {
|
|
// 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
|
|
*/
|
|
public 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
|
|
*/
|
|
/**
|
|
* 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) {
|
|
|
|
|
|
// 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) {
|
|
// 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 metadata available to the included file
|
|
$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;
|
|
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
|
|
|
|
if (!file_exists($guideFile)) {
|
|
$guideFile = __DIR__ . '/../../../guide/en.codepress.md'; // Fallback to English
|
|
}
|
|
|
|
$content = file_get_contents($guideFile);
|
|
|
|
// Parse metadata first
|
|
$parsed = $this->parseMetadata($content);
|
|
$metadata = $parsed['metadata'];
|
|
$contentWithoutMeta = $parsed['content'];
|
|
|
|
// 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($contentWithoutMeta)->getContent();
|
|
|
|
// Extract title from metadata or first H1
|
|
$title = $metadata['title'] ?? '';
|
|
if (empty($title) && preg_match('/^#\s+(.+)$/m', $contentWithoutMeta, $matches)) {
|
|
$title = trim($matches[1]);
|
|
}
|
|
|
|
// Set special title for guide
|
|
$title = $this->t('manual') . ' - CodePress CMS';
|
|
|
|
return [
|
|
'title' => $title,
|
|
'content' => $body,
|
|
'metadata' => $metadata,
|
|
'layout' => $metadata['layout'] ?? 'content'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Detect user language from browser headers
|
|
*
|
|
* @return string Language code ('nl' or 'en')
|
|
*/
|
|
/**
|
|
* Generate directory listing page
|
|
*
|
|
* @param string $pagePath Relative path to directory
|
|
* @param string $dirPath Absolute path to directory
|
|
* @return array Directory listing page data
|
|
*/
|
|
private function getDirectoryListing($pagePath, $dirPath) {
|
|
// Get the directory name from the path, not from a potential file
|
|
$pathParts = explode('/', $pagePath);
|
|
$dirName = end($pathParts);
|
|
|
|
|
|
|
|
$title = $this->formatDisplayName($dirName) ?: 'Home';
|
|
|
|
$content = '<h1>' . htmlspecialchars($title) . '</h1>';
|
|
$result = [
|
|
'title' => $title,
|
|
'content' => $content
|
|
];
|
|
|
|
// Debug: ensure we're returning the right 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();
|
|
|
|
// Get sidebar content from plugins
|
|
$sidebarContent = $this->pluginManager->getSidebarContent();
|
|
|
|
// Get layout from page metadata
|
|
$layout = $page['layout'] ?? 'sidebar-content';
|
|
|
|
// Prepare template data
|
|
$templateData = [
|
|
'site_title' => $this->config['site_title'],
|
|
'page_title' => htmlspecialchars($page['title']),
|
|
|
|
'content' => $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->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' => 'https://git.noorlander.info/E.Noorlander',
|
|
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
|
|
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
|
|
'cms_version' => isset($this->config['version_info']) ? 'v' . $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',
|
|
'navigation_color' => $this->config['theme']['navigation_color'] ?? '#f8f9fa',
|
|
'navigation_font_color' => $this->config['theme']['navigation_font_color'] ?? '#000000',
|
|
'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),
|
|
'available_langs' => array_map(function($lang) {
|
|
$lang['is_current'] = $lang['code'] === $this->currentLanguage;
|
|
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['file_info_block'] = true;
|
|
} else {
|
|
$templateData['created'] = '';
|
|
$templateData['modified'] = '';
|
|
$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']);
|
|
|
|
// 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
|
|
*/
|
|
public 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 = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
|
|
$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 = htmlspecialchars($pagePath, ENT_QUOTES, 'UTF-8');
|
|
$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;
|
|
}
|
|
} |