- config.json now has default_page: auto for fresh installs - CodePressCMS::detectDefaultPage() scans content/ for first available file - getHomepageTitle() also respects auto mode - Admin config form preserves auto as selectable option - Save handler falls back to auto instead of index
1417 lines
55 KiB
PHP
1417 lines
55 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 (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 = 'index', $lang = null, $params = []) {
|
|
$lang = $lang ?: $this->currentLanguage;
|
|
$url = '/' . $lang;
|
|
if ($page && $page !== 'index') {
|
|
$url .= '/' . $page;
|
|
}
|
|
if (!empty($params)) {
|
|
$url .= '?' . http_build_query($params);
|
|
}
|
|
return $url;
|
|
}
|
|
|
|
/**
|
|
* 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 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'], '');
|
|
$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->config['default_page'];
|
|
if ($page === 'auto') {
|
|
$page = $this->detectDefaultPage();
|
|
}
|
|
// 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);
|
|
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);
|
|
// Use birthtime if available (macOS/BSD), fall back to mtime on Linux where ctime is inode change time
|
|
$createdTimestamp = $stats['birthtime'] ?? $stats['mtime'];
|
|
$created = date('d-m-Y H:i', $createdTimestamp);
|
|
$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
|
|
* @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' => [
|
|
'html_class' => 'heading-permalink',
|
|
'id_prefix' => '',
|
|
'insert' => 'after',
|
|
'min_heading_level' => 1,
|
|
'max_heading_level' => 6,
|
|
'title' => 'Permalink',
|
|
'symbol' => '',
|
|
'aria_hidden' => true,
|
|
],
|
|
];
|
|
|
|
// 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());
|
|
|
|
// Handle custom image size syntax: {: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) {
|
|
$hasLeadingDash = $filename[0] === '-';
|
|
if ($hasLeadingDash) {
|
|
$filename = substr($filename, 1);
|
|
}
|
|
|
|
$availableLangs = array_keys($this->getAvailableLanguages());
|
|
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
|
|
if (preg_match($langPattern, $filename, $matches)) {
|
|
$filename = $matches[2];
|
|
}
|
|
|
|
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
|
|
|
|
$name = str_replace(['-', '_'], ' ', $filename);
|
|
$name = trim($name);
|
|
$name = ucwords(strtolower($name));
|
|
|
|
$specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT'];
|
|
$lower = strtolower($name);
|
|
if (isset($specialCases[$lower])) {
|
|
$name = $specialCases[$lower];
|
|
} else {
|
|
$name = 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;
|
|
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
|
|
|
|
if (!file_exists($guideFile)) {
|
|
$guideFile = __DIR__ . '/../../../guide/en.codepress.md'; // Fallback to English
|
|
}
|
|
|
|
$content = file_get_contents($guideFile);
|
|
|
|
// Reuse parseMarkdown to avoid duplicating CommonMark setup
|
|
$result = $this->parseMarkdown($content, $guideFile);
|
|
|
|
// Override title for guide
|
|
$result['title'] = $this->t('manual') . ' - CodePress CMS';
|
|
$result['layout'] = $result['metadata']['layout'] ?? 'content';
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 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' => $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>';
|
|
}
|
|
|
|
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();
|
|
$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
|
|
$hasSidebar = $layout !== 'content' && !empty(trim($sidebarContent));
|
|
$breadcrumb = $this->generateBreadcrumb($hasSidebar);
|
|
|
|
// 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->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' => '',
|
|
'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' => ($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',
|
|
'background_image_css' => $this->getBackgroundImageCss(),
|
|
'background_image_opacity' => $this->getBackgroundImageOpacity(),
|
|
// Language
|
|
'current_lang' => $this->currentLanguage,
|
|
'current_lang_upper' => strtoupper($this->currentLanguage),
|
|
'current_page' => $_GET['page'] ?? $this->config['default_page'],
|
|
'available_langs' => array_map(function($lang) {
|
|
$lang['is_current'] = $lang['code'] === $this->currentLanguage;
|
|
$page = $_GET['page'] ?? $this->config['default_page'];
|
|
$lang['url'] = '/' . $lang['code'] . ($page !== $this->config['default_page'] ? '/' . $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['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;
|
|
|
|
$this->pluginManager->doAction('onAfterRender', $renderedLayout);
|
|
}
|
|
|
|
/**
|
|
* Generate breadcrumb navigation HTML
|
|
*
|
|
* @param bool $hasSidebar Whether sidebar content exists and should show toggle
|
|
* @return string Breadcrumb HTML
|
|
*/
|
|
public function generateBreadcrumb($hasSidebar = true) {
|
|
// 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>';
|
|
}
|
|
|
|
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->config['default_page'];
|
|
$page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
|
|
$page = preg_replace('/\.[^.]+$/', '', $page);
|
|
|
|
// Convert page to clean URL format for breadcrumb
|
|
if ($page === $this->config['default_page']) {
|
|
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item active"><i class="bi bi-house"></i></li></ol></nav>';
|
|
}
|
|
|
|
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
|
|
|
|
// Start with sidebar toggle, then home icon linking to default page (root)
|
|
$breadcrumb .= $sidebarToggle;
|
|
$breadcrumb .= '<li class="breadcrumb-item"><a href="/' . $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 = 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
|
|
$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';
|
|
}
|
|
|
|
/**
|
|
* 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'];
|
|
}
|
|
|
|
/**
|
|
* Get homepage title
|
|
*
|
|
* @return string Homepage title
|
|
*/
|
|
private function getHomepageTitle() {
|
|
// Use formatted filename for homepage title in navigation
|
|
$page = $this->config['default_page'];
|
|
if ($page === 'auto') {
|
|
$page = $this->detectDefaultPage();
|
|
}
|
|
return $this->formatDisplayName($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;
|
|
}
|
|
|
|
private function getBackgroundImageCss(): string
|
|
{
|
|
$bg = $this->config['theme']['background_image'] ?? '';
|
|
if (empty($bg)) {
|
|
return 'none';
|
|
}
|
|
if (str_starts_with($bg, 'http')) {
|
|
return 'url(' . $bg . ')';
|
|
}
|
|
return 'url(/themes/' . $bg . ')';
|
|
}
|
|
|
|
private function getBackgroundImageOpacity(): int
|
|
{
|
|
$opacity = intval($this->config['theme']['background_image_opacity'] ?? 100);
|
|
return max(0, min(100, $opacity));
|
|
}
|
|
|
|
private function processContent(string $content): string
|
|
{
|
|
return str_replace('-/assets/', '/-assets/', $content);
|
|
}
|
|
} |