Files
CodePress/cms/core/class/CodePressCMS.php
T

2117 lines
82 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Hoofdclass van het file-based CodePress CMS.
*
* Lichtgewicht content management systeem zonder database. Ondersteunt
* Markdown, PHP en HTML content, dynamische navigatie met dropdown-menus,
* zoekfunctionaliteit, breadcrumb-navigatie, auto-linking tussen pagina's,
* Bootstrap 5 styling, file-based organisatie, SEO-vriendelijke URLs en
* responsive design.
*
* @since 2.6.4
*/
class CodePressCMS {
/**
* Configuratie-array met alle site-instellingen.
*
* @since 2.6.4
* @var array
*/
public $config;
/**
* Actieve taalcode van het huidige verzoek (bijv. 'nl' of 'en').
*
* @since 2.6.4
* @var string
*/
public $currentLanguage;
/**
* Resultaten van de laatst uitgevoerde zoekopdracht.
*
* @since 2.6.4
* @var array
*/
public $searchResults = [];
/**
* Opgebouwde menu-structuur vanuit de content-directory.
*
* @since 2.6.4
* @var array
*/
private $menu = [];
/**
* Effectieve standaardpagina die op de taal-root URL wordt geserveerd.
*
* Wordt lazy geinitialiseerd in getEffectiveDefaultPage().
*
* @since 2.6.4
* @var string|null
*/
private ?string $effectiveDefaultPage = null;
/**
* Geladen vertalingen voor de actieve taal.
*
* @since 2.6.4
* @var array
*/
private $translations = [];
/**
* PluginManager-instance die plugins en hooks beheert.
*
* @since 2.6.4
* @var PluginManager
*/
private $pluginManager;
/**
* Initialiseer het CMS met de opgegeven configuratie.
*
* Laadt versie-informatie, stelt de actieve taal en vertalingen in,
* initialiseert de PluginManager en de CMSAPI, bouwt het menu op en
* voert een eventuele zoekopdracht uit.
*
* @since 2.6.4
*
* @param array $config Configuration array containing site settings.
* @return void
*/
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'] ?? [];
$siteDefaultLang = $this->config['language']['default'] ?? 'nl';
$this->pluginManager = new PluginManager(__DIR__ . '/../../../plugins', $enabledPlugins, $siteDefaultLang);
$api = new CMSAPI($this);
$api->setPluginManager($this->pluginManager);
$this->pluginManager->setAPI($api);
$this->buildMenu();
if (isset($_GET['search'])) {
$this->performSearch($_GET['search']);
}
}
/**
* Bouw een clean URL voor een pagina.
*
* @since 2.6.4
*
* @param string|null $page Page path (e.g., 'blog/leren/artikel').
* @param string|null $lang Language code.
* @param array $params Additional query parameters.
* @return string Clean URL.
*/
public function buildUrl($page = null, $lang = null, $params = []) {
$lang = $lang ?: $this->currentLanguage;
$url = '/' . $lang;
// Only omit the page segment for the actual homepage (default_page),
// not for a page that happens to be called 'index'
if ($page && $page !== $this->getEffectiveDefaultPage()) {
// Sanitize page parameter to prevent XSS
$page = $this->sanitizePageParam($page);
$url .= '/' . $page;
}
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $url;
}
/**
* Sanitize de pagina-parameter om XSS-aanvallen te voorkomen.
*
* Verwijdert alle tekens die niet alfanumeriek, streepje, underscore of
* slash zijn. De punt blijft behouden zodat content-bestandsextensies
* (md/php/html) in de URL overleven.
*
* @since 2.6.4
*
* @param string $page Page parameter.
* @return string Gesanitizeerde pagina-parameter of 'invalid-page'.
*/
private function sanitizePageParam(string $page): string {
// Remove any characters that could be used for XSS; keep the dot so
// content file extensions (md/php/html) survive in the URL.
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/.]/', '', $page);
return $sanitized ?: 'invalid-page';
}
/**
* Bepaal de effectieve standaardpagina (behandelt 'auto' en 'newest' modus).
*
* Het resultaat wordt gecached in $effectiveDefaultPage.
*
* @since 2.6.4
*
* @return string Page key that is served on the language root URL.
*/
public function getEffectiveDefaultPage(): string
{
if ($this->effectiveDefaultPage === null) {
$page = $this->config['default_page'] ?? 'auto';
$this->effectiveDefaultPage = match ($page) {
'auto' => $this->detectDefaultPage(),
'newest' => $this->detectNewestPage(),
default => $page,
};
}
return $this->effectiveDefaultPage;
}
/**
* Bouw een clean admin URL.
*
* @since 2.6.4
*
* @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;
}
/**
* Bouw een URL-string voor gebruik in templates (niet-statisch, met huidige taal-context).
*
* @since 2.6.4
*
* @param string $page Pagina-pad.
* @param array $extraParams Extra query parameters.
* @return string Clean URL.
*/
public function url($page = 'index', $extraParams = []) {
return $this->buildUrl($page, $this->currentLanguage, $extraParams);
}
/**
* Bepaal de actieve taal vanuit het verzoek of de configuratie.
*
* @since 2.6.4
*
* @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');
}
/**
* Verzamel alle beschikbare talen vanuit de language-directory.
*
* Elke taal is een subdirectory onder language/ met een site.php-bestand.
*
* @since 2.6.4
*
* @return array Available languages with their codes and names.
*/
public function getAvailableLanguages() {
$langDir = __DIR__ . '/../../../language/';
$languages = [];
if (!is_dir($langDir)) {
return $languages;
}
$entries = scandir($langDir);
foreach ($entries as $entry) {
if (preg_match('/^[a-z]{2}$/', $entry) && is_dir($langDir . $entry)) {
$langCode = $entry;
$siteFile = $langDir . $entry . '/site.php';
if (file_exists($siteFile)) {
$translations = include $siteFile;
if (!is_array($translations)) {
continue;
}
$languages[$langCode] = [
'code' => $langCode,
'name' => $translations['site_title'] ?? strtoupper($langCode),
'native_name' => $this->getNativeLanguageName($langCode)
];
}
}
}
return $languages;
}
/**
* Laad site-vertalingen voor de opgegeven taal.
*
* Valt terug op de standaardtaal en daarna op een lege array.
*
* @since 2.6.4
*
* @param string $lang Language code.
* @return array Translations array.
*/
private function loadTranslations($lang) {
$langDir = __DIR__ . '/../../../language/';
$langFile = $langDir . $lang . '/site.php';
if (file_exists($langFile)) {
$translations = include $langFile;
if (is_array($translations)) {
return $translations;
}
}
// Fallback to default language
$defaultLang = $this->config['language']['default'] ?? 'nl';
$defaultLangFile = $langDir . $defaultLang . '/site.php';
if (file_exists($defaultLangFile)) {
$translations = include $defaultLangFile;
if (is_array($translations)) {
return $translations;
}
}
// Return empty array if no translation found
return [];
}
/**
* Geef de native taalnaam voor een taalcode terug.
*
* Bevat een statische mapping van ISO-codes naar native taalnamen.
*
* @since 2.6.4
*
* @param string $langCode Language code.
* @return string Native language name.
*/
private function getNativeLanguageName($langCode) {
$names = [
'af' => 'Afrikaans',
'am' => 'አማርኛ',
'ar' => 'العربية',
'az' => 'Azərbaycan',
'bg' => 'Български',
'bn' => 'বাংলা',
'bs' => 'Bosanski',
'ca' => 'Català',
'cs' => 'Čeština',
'da' => 'Dansk',
'de' => 'Deutsch',
'el' => 'Ελληνικά',
'en' => 'English',
'es' => 'Español',
'et' => 'Eesti',
'eu' => 'Euskara',
'fa' => 'فارسی',
'fi' => 'Suomi',
'fil' => 'Filipino',
'fr' => 'Français',
'ga' => 'Gaeilge',
'gl' => 'Galego',
'gu' => 'ગુજરાતી',
'he' => 'עברית',
'hi' => 'हिन्दी',
'hr' => 'Hrvatski',
'hu' => 'Magyar',
'hy' => 'Հայերեն',
'id' => 'Bahasa Indonesia',
'is' => 'Íslenska',
'it' => 'Italiano',
'ja' => '日本語',
'ka' => 'ქართული',
'kk' => 'Қазақша',
'km' => 'ខ្មែរ',
'ko' => '한국어',
'lo' => 'ລາວ',
'lt' => 'Lietuvių',
'lv' => 'Latviešu',
'mk' => 'Македонски',
'mn' => 'Монгол',
'mr' => 'मराठी',
'ms' => 'Bahasa Melayu',
'mt' => 'Malti',
'my' => 'မြန်မာ',
'ne' => 'नेपाली',
'nl' => 'Nederlands',
'no' => 'Norsk',
'pa' => 'ਪੰਜਾਬੀ',
'pl' => 'Polski',
'pt' => 'Português',
'ro' => 'Română',
'ru' => 'Русский',
'si' => 'සිංහල',
'sk' => 'Slovenčina',
'sl' => 'Slovenščina',
'sq' => 'Shqip',
'sr' => 'Српски',
'sv' => 'Svenska',
'sw' => 'Kiswahili',
'ta' => 'தமிழ்',
'te' => 'తెలుగు',
'th' => 'ไทย',
'tl' => 'Tagalog',
'tr' => 'Türkçe',
'uk' => 'Українська',
'ur' => 'اردو',
'uz' => 'Oʻzbek',
'vi' => 'Tiếng Việt',
'zh' => '中文',
];
return $names[$langCode] ?? strtoupper($langCode);
}
/**
* Geef vertaalde tekst voor een sleutel terug.
*
* @since 2.6.4
*
* @param string $key Translation key.
* @return string Translated text, of de sleutel zelf indien geen vertaling gevonden.
*/
public function t($key) {
return $this->translations[$key] ?? $key;
}
/**
* Bouw de menu-structuur op vanuit de content-directory.
*
* Roept de plugin-actie 'onMenuBuild' aan na het scannen.
*
* @since 2.6.4
* @return void
*/
private function buildMenu() {
$this->menu = $this->scanDirectory($this->config['content_dir'], '');
$this->pluginManager->doAction('onMenuBuild', $this->menu);
}
/**
* Scan recursief een directory voor content-bestanden en mappen.
*
* @since 2.6.4
*
* @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) {
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
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);
// Keep the extension in the URL so files sharing the same name
// but different type each keep their own viewable link.
$result[] = [
'type' => 'file',
'title' => $title,
'path' => $relativePath,
'url' => $this->buildUrl($relativePath)
];
}
}
return $result;
}
/**
* Voer een zoekopdracht uit over alle content-bestanden.
*
* Reset de zoekresultaten, doorzoekt de content-directory en vuurt de
* plugin-actie 'onSearch' af.
*
* @since 2.6.4
*
* @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);
}
/**
* Doorzoek recursief de bestanden in een directory naar de query.
*
* @since 2.6.4
*
* @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) {
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $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)
];
}
}
}
}
/**
* Maak een zoeksnippet met de gevonden query.
*
* @since 2.6.4
*
* @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 . '...';
}
/**
* Bepaal de content van de huidige pagina op basis van het verzoek.
*
* Behandelt zoek-, guide-, welcome- en 404-verzoeken, path-traversal-
* validatie, directory-listings en taal-specifieke bestandsversies.
*
* @since 2.6.4
* @return array Page data with title and content.
*/
public function getPage() {
if (isset($_GET['search'])) {
return $this->getSearchResults();
}
// Check if guide is requested (either via ?guide or /guide clean URL)
$pageCheck = $_GET['page'] ?? '';
if (isset($_GET['guide']) || $pageCheck === 'guide') {
return $this->getGuidePage();
}
// Determine if the request has a valid language prefix
$availableLangs = array_keys($this->getAvailableLanguages());
$requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';
$requestPath = ltrim($requestPath, '/');
$langFromUrl = '';
if ($requestPath !== '' && in_array(explode('/', $requestPath)[0], $availableLangs, true)) {
$langFromUrl = explode('/', $requestPath)[0];
}
// No language prefix and not the root → 404
if ($langFromUrl === '' && $requestPath !== '' && $requestPath !== 'favicon.ico') {
return $this->getError404();
}
// Check if content directory is empty — show welcome page for new installations
if ($this->isContentDirEmpty()) {
$requestedPage = $_GET['page'] ?? '';
// Only show welcome page on the home URL; everything else is a 404
if (empty($requestedPage) || $requestedPage === $this->getEffectiveDefaultPage()) {
return $this->getWelcomePage();
}
return $this->getError404();
}
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
// Limit length
$page = substr($page, 0, 255);
// Detect explicit extension from the URL (e.g. /nl/test.php → use test.php)
$explicitExt = null;
if (preg_match('/\.(md|php|html)$/i', $page, $extMatch)) {
$explicitExt = strtolower($extMatch[1]);
}
// Strip any content extension from the URL for the base path
$pageWithoutExt = preg_replace('/\.(md|php|html)$/i', '', $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;
// If the URL has an explicit extension, try that exact file first
if ($explicitExt !== null && file_exists($filePath . '.' . $explicitExt)) {
$actualFilePath = $filePath . '.' . $explicitExt;
$result = $this->resolveContentByType($filePath, $explicitExt);
if ($result !== null) {
$actualFilePath = $result['path'];
$result = $result['content'];
}
}
// No explicit extension: serve the first matching file (md > php > html)
if (!isset($result)) {
$resolved = $this->resolveContentByType($filePath);
if ($resolved !== null) {
$actualFilePath = $resolved['path'];
$result = $resolved['content'];
}
}
// If no exact match found, check for language-specific versions
if (!isset($result)) {
$langPrefix = $this->currentLanguage;
$langBase = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt;
$resolved = $this->resolveContentByType($langBase, $explicitExt);
if ($resolved !== null) {
$actualFilePath = $resolved['path'];
$result = $resolved['content'];
}
}
// If no file found, check if it's a directory (directories take precedence)
if (!isset($result) && is_dir($filePath)) {
return $this->getDirectoryListing($pageWithoutExt, $filePath);
}
if (isset($result) && $actualFilePath) {
$result['file_info'] = $this->getFileInfo($actualFilePath, $result['metadata'] ?? []);
return $result;
}
return $this->getError404();
}
/**
* Bepaal het eerste bestaande content-bestand voor een base-path op type-prioriteit.
*
* Een verzoek zonder expliciete extensie (bijv. "test") serveert het
* eerste bestaande bestand uit test.md -> test.php -> test.html. Wanneer
* $preferredExt is opgegeven (de URL bevatte een extensie) wordt dat
* type eerst geprobeerd, zodat /nl/test.php test.php serveert ook als
* test.md bestaat.
*
* @since 2.6.4
*
* @param string $basePath Absolute path without extension.
* @param string|null $preferredExt Optional extension to try first (md|php|html).
* @return array|null ['path' => string, 'content' => array] or null.
*/
private function resolveContentByType(string $basePath, ?string $preferredExt = null): ?array
{
$order = ['md', 'php', 'html'];
if ($preferredExt !== null && in_array($preferredExt, $order, true)) {
$order = array_unique(array_merge([$preferredExt], $order));
}
foreach ($order as $ext) {
$candidate = $basePath . '.' . $ext;
if (file_exists($candidate)) {
if ($ext === 'md') {
$content = $this->parseMarkdown(file_get_contents($candidate), $candidate);
} elseif ($ext === 'php') {
$content = $this->parsePHP($candidate);
} else {
$content = $this->parseHTML(file_get_contents($candidate));
}
return ['path' => $candidate, 'content' => $content];
}
}
return null;
}
/**
* Verzamel bestandsinformatie inclusief aanmaak- en wijzigingsdatums.
*
* @since 2.6.4
*
* @param string $filePath Path to the file.
* @param array $metadata Optional frontmatter metadata.
* @return array|null File information or null if file doesn't exist.
*/
private function getFileInfo($filePath, array $metadata = []) {
if (!file_exists($filePath)) {
return null;
}
$stats = stat($filePath);
$modified = date('d-m-Y H:i', $stats['mtime']);
// 1. Check if created date is present in frontmatter metadata
$metaCreated = $metadata['created'] ?? $metadata['date'] ?? $metadata['date_created'] ?? $metadata['created_at'] ?? null;
if ($metaCreated) {
if (is_numeric($metaCreated)) {
$created = date('d-m-Y H:i', (int)$metaCreated);
} else {
$created = (string)$metaCreated;
}
} else {
// 2. Fallback to ctime (inode change time / creation time) or mtime
$createdTimestamp = $stats['ctime'] ?? $stats['mtime'];
$created = date('d-m-Y H:i', $createdTimestamp);
}
// Show created date only if it is distinctly different from modified date
$showCreated = ($created !== $modified);
return [
'created' => $created,
'modified' => $modified,
'show_created' => $showCreated,
'size' => $this->formatFileSize($stats['size'])
];
}
/**
* Format een bestandsgrootte in leesbare vorm (B, KB, MB, GB).
*
* @since 2.6.4
*
* @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];
}
/**
* Bouw de content voor de zoekresultatenpagina.
*
* @since 2.6.4
* @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 YAML-frontmatter metadata uit de content.
*
* Herkent een blok dat begint en eindigt met `---` en levert een array
* met de metadata en de content zonder het meta-blok.
*
* @since 2.6.4
*
* @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
];
}
/**
* Geef de hosts die gelden als intern voor externe-link-detectie.
*
* Bevat de huidige HTTP_HOST (inclusief www-variant) en eventueel de
* author-website host.
*
* @since 2.6.4
* @return array List of host names.
*/
private function getInternalHosts(): array
{
$hosts = [];
if (!empty($_SERVER['HTTP_HOST'])) {
$host = strtolower(preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST']));
if ($host !== '') {
$hosts[] = $host;
// Treat www and apex variants as the same site
$hosts[] = str_starts_with($host, 'www.') ? substr($host, 4) : 'www.' . $host;
}
}
$authorWebsite = $this->normalizeUrl($this->config['author']['website'] ?? '');
if ($authorWebsite !== '') {
$authorHost = parse_url($authorWebsite, PHP_URL_HOST);
if ($authorHost) {
$hosts[] = strtolower($authorHost);
}
}
return array_values(array_unique(array_filter($hosts)));
}
/**
* Normaliseer een URL: ontbreekt een scheme, dan wordt https:// voorgevoegd.
*
* Behandelt hostnames die zonder protocol zijn opgeslagen (bijv. "noorlander.info").
*
* @since 2.6.4
*
* @param string $url Raw URL or hostname.
* @return string Normalized absolute URL, or '' if empty.
*/
private function normalizeUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) {
return 'https://' . $url;
}
return $url;
}
/**
* Parse Markdown-content naar HTML via League CommonMark.
*
* Trekt eerst frontmatter-metadata uit, bepaalt de titel en configureert
* de CommonMark-omgeving met extensies en custom afbeeldingsgrootte-syntax.
*
* @since 2.6.4
*
* @param string $content Raw Markdown content.
* @param string $actualFilePath Path to the source file (used for display name fallback).
* @return array Parsed content with title and body.
*/
public function parseMarkdown($content, $actualFilePath = '') {
// Parse metadata first
$parsed = $this->parseMetadata($content);
$metadata = $parsed['metadata'];
$content = $parsed['content'];
// Extract title from first H1 or metadata
$title = $metadata['title'] ?? '';
if (empty($title) && preg_match('/^#\s+(.+)$/m', $content, $matches)) {
$title = trim($matches[1]);
}
// Configure CommonMark environment (autoloader already loaded in bootstrap)
$config = [
'html_input' => 'strip',
'allow_unsafe_links' => false,
'max_nesting_level' => 100,
'heading_permalink' => [
'symbol' => '',
'aria_hidden' => true,
'html_class' => 'heading-permalink',
'id_prefix' => '',
'fragment_prefix' => '',
'apply_id_to_heading' => true,
'insert' => 'after',
'min_heading_level' => 1,
'max_heading_level' => 6,
'title' => 'Permalink',
],
'external_link' => [
'internal_hosts' => $this->getInternalHosts(),
'open_in_new_window' => true,
'html_class' => 'external-link',
'nofollow' => '',
'noopener' => 'external',
'noreferrer' => 'external',
],
];
// Create environment with extensions
$environment = new \League\CommonMark\Environment\Environment($config);
$environment->addExtension(new \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension());
$environment->addExtension(new \League\CommonMark\Extension\Autolink\AutolinkExtension());
$environment->addExtension(new \League\CommonMark\Extension\Strikethrough\StrikethroughExtension());
$environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension());
$environment->addExtension(new \League\CommonMark\Extension\TaskList\TaskListExtension());
$environment->addExtension(new \League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension());
$environment->addExtension(new \League\CommonMark\Extension\ExternalLink\ExternalLinkExtension());
// Handle custom image size syntax: ![alt](url){:width="300" height="200"}
$imagePlaceholders = [];
$content = preg_replace_callback('/!\[([^\]]*)\]\(([^)]+)\)\{:([^}]+)\}/', function ($m) use (&$imagePlaceholders) {
$index = count($imagePlaceholders);
$alt = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
$url = htmlspecialchars($m[2], ENT_QUOTES, 'UTF-8');
$attrs = trim($m[3]);
$imagePlaceholders[$index] = '<img src="' . $url . '" alt="' . $alt . '" ' . $attrs . '>';
return '@@IMG_' . $index . '@@';
}, $content);
// Create converter
$converter = new \League\CommonMark\MarkdownConverter($environment);
// Convert to HTML
$body = $converter->convert($content)->getContent();
// Restore custom image tags
foreach ($imagePlaceholders as $index => $imgTag) {
$body = str_replace('@@IMG_' . $index . '@@', $imgTag, $body);
}
// Extract clean filename for title (without language prefix and extension)
$filename = basename($actualFilePath);
$cleanName = $this->formatDisplayName($filename);
// Auto-link page titles to existing content pages (but not in H1 tags)
if ($this->config['features']['auto_link_pages'] ?? true) {
$body = $this->autoLinkPageTitles($body, $cleanName);
}
// Convert relative internal links to clean URLs (skip already-prefixed URLs)
$body = preg_replace('/href="\/blog\/([^"]+)"/', 'href="/' . $this->currentLanguage . '/blog/$1"', $body);
$body = preg_replace('/href="\/(?!nl\/|en\/|nl"|en")([^"]+)"/', 'href="/' . $this->currentLanguage . '/$1"', $body);
return [
'title' => $title ?: $cleanName ?: 'Untitled',
'content' => $body,
'metadata' => $metadata,
'layout' => $metadata['layout'] ?? 'sidebar-content'
];
}
/**
* Auto-link pagina-titels die in de content worden gevonden.
*
* Bestaande `<a>`-tags, `<h1>`-inhoud en Markdown-links worden beschermd
* tegen dubbele linking. De titel van de huidige pagina wordt overgeslagen.
*
* @since 2.6.4
*
* @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 $page) {
$pageTitle = $page['title'];
$pagePath = $page['path'];
// Alleen bestanden auto-linken, geen mappen
if ($page['type'] === 'folder') continue;
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;
}
/**
* Verzamel alle content-entries (mappen + bestanden) uit de content-directory.
*
* @since 2.6.4
*
* @return array List of entries: ['path' => ..., 'title' => ..., 'type' => ...].
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
*/
public function getAllPageTitles() {
$pages = [];
$this->scanForPageTitles($this->config['content_dir'], '', $pages);
return $pages;
}
/**
* Scan recursief een directory voor content-entries.
*
* Levert een platte lijst van ['path' => relativePath, 'title' => ..., 'type' => ...].
* Mappen krijgen type 'folder'; bestanden krijgen hun extensie als type.
* Bestanden met dezelfde naam maar ander type blijven apart (de extensie
* zit in het pad), en mappen krijgen hun eigen entry zodat de boom zichtbaar
* blijft.
*
* @since 2.6.4
*
* @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) {
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) {
$pages[] = [
'path' => $relativePath,
'title' => $this->formatDisplayName($item),
'type' => 'folder',
];
$this->scanForPageTitles($path, $relativePath, $pages);
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
$title = $this->extractPageTitle($path);
if (!$title || empty(trim($title))) {
$filename = basename($path, pathinfo($path, PATHINFO_EXTENSION));
$title = $this->formatDisplayName($filename);
}
$pages[] = [
'path' => $relativePath,
'title' => $title,
'type' => pathinfo($item, PATHINFO_EXTENSION),
];
}
}
}
/**
* Format een bestands- of mapnaam tot een leesbare weergavenaam.
*
* Verwijdert taal-prefixen (op basis van getAvailableLanguages()) en
* content-extensies, zet streepjes/underscores om in spaties en past
* special cases toe (bijv. 'ict' -> 'ICT').
*
* @since 2.6.4
*
* @param string $filename Filename without extension.
* @return string Formatted display name.
*/
private function formatDisplayName($filename) {
$filename = (string)$filename;
if ($filename === '') {
return '';
}
$hasLeadingDash = $filename[0] === '-';
if ($hasLeadingDash) {
$filename = substr($filename, 1);
}
$availableLangs = array_keys($this->getAvailableLanguages());
if (!empty($availableLangs)) {
$langPattern = '/^(' . implode('|', array_map('preg_quote', $availableLangs)) . ')\.(.+)$/';
if (preg_match($langPattern, $filename, $matches)) {
$filename = $matches[2];
}
}
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
$name = trim(str_replace(['-', '_'], ' ', $filename));
$name = ucwords(strtolower($name));
$specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT'];
$lower = strtolower($name);
$name = $specialCases[$lower]
?? str_ireplace(array_keys($specialCases), array_values($specialCases), $name);
return ($hasLeadingDash ? '- ' : '') . $name;
}
/**
* Extract de paginatitel uit de content van een bestand.
*
* Voor Markdown wordt het eerste H1 gebruikt, voor PHP een $title-
* variabele en voor HTML de <title>- of <h1>-tag.
*
* @since 2.6.4
*
* @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 een PHP-bestand en vang de output op.
*
* Stelt ContentAPI en frontmatter-metadata beschikbaar aan het
* geinclude bestand. Wanneer het bestand een string returnt wordt
* die als content gebruikt in plaats van de gebufferde output.
*
* @since 2.6.4
*
* @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;
$returnedContent = include $filePath;
$content = ob_get_clean();
// If the PHP file returned a string, use that as content (callback style).
// Otherwise use the buffered echo output.
if (is_string($returnedContent) && $returnedContent !== '') {
$content = $returnedContent;
}
// 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 en extraheer de titel.
*
* @since 2.6.4
*
* @param string $content Raw HTML content.
* @param string $actualFilePath Path to the source file (used for display name fallback).
* @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'
];
}
/**
* Controleer of de content-directory leeg is.
*
* @since 2.6.4
*
* @return bool True if content directory is empty or doesn't exist.
*/
public function isContentDirEmpty() {
$contentDir = $this->config['content_dir'];
if (!is_dir($contentDir)) {
return true;
}
$files = scandir($contentDir);
$files = array_diff($files, ['.', '..']);
// Filter out hidden files (e.g. .gitkeep) — only count visible content
$files = array_filter($files, function($f) {
return $f[0] !== '.';
});
return empty($files);
}
/**
* Geef de content voor de welkomstpagina bij nieuwe installaties.
*
* Toont een duidelijke "nieuwe installatie"-boodschap met vervolgstappen
* wanneer de content-directory nog geen content-bestanden bevat.
*
* @since 2.6.4
* @return array Welcome page data.
*/
private function getWelcomePage() {
$adminUrl = $this->buildAdminUrl();
$guideUrl = '/' . $this->currentLanguage . '/guide';
$content = '<div class="welcome-page">';
$content .= '<h1>' . htmlspecialchars($this->t('welcome_title')) . '</h1>';
$content .= '<p class="alert alert-info" role="alert">';
$content .= '<i class="bi bi-info-circle me-2" aria-hidden="true"></i>';
$content .= htmlspecialchars($this->t('welcome_intro'));
$content .= '</p>';
$content .= '<h2>' . htmlspecialchars($this->t('welcome_next_steps')) . '</h2>';
$content .= '<ol class="list-group list-group-numbered mb-4">';
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_1')) . '</li>';
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_2')) . '</li>';
$content .= '<li class="list-group-item">' . htmlspecialchars($this->t('welcome_step_3')) . '</li>';
$content .= '</ol>';
$content .= '<div class="d-flex flex-column flex-md-row gap-2 mb-4">';
$content .= '<a href="' . htmlspecialchars($adminUrl) . '" class="btn btn-primary">';
$content .= '<i class="bi bi-gear me-1" aria-hidden="true"></i> ';
$content .= htmlspecialchars($this->t('welcome_admin_link'));
$content .= '</a>';
$content .= '<a href="' . htmlspecialchars($guideUrl) . '" class="btn btn-outline-secondary">';
$content .= '<i class="bi bi-book me-1" aria-hidden="true"></i> ';
$content .= htmlspecialchars($this->t('welcome_guide_link'));
$content .= '</a>';
$content .= '</div>';
$content .= '</div>';
return [
'title' => $this->t('welcome_title'),
'content' => $content,
'layout' => 'full_content',
'metadata' => [],
];
}
/**
* Geef de content van een handleiding-pagina op basis van de gebruikerstaal.
*
* Valt terug op het Engelse equivalent indien de gevraagde taal niet
* beschikbaar is. Bouwt tevens breadcrumbs op voor de handleiding.
*
* @since 2.6.4
* @return array Guide page data.
*/
private function getGuidePage() {
$lang = $this->currentLanguage;
$pagePath = $_GET['page'] ?? '';
// Special case: /nl/guide sets page='guide', but we want index
if ($pagePath === 'guide') {
$pagePath = '';
}
$rootDir = dirname(__DIR__, 3);
$guideDir = $rootDir . '/guide/' . $lang;
// Get guide file
if (empty($pagePath)) {
$guideFile = $guideDir . '/index.md';
} else {
$pagePath = trim($pagePath, '/');
$guideFile = $guideDir . '/' . $pagePath . '.md';
}
// Fallback to English
if (!file_exists($guideFile) && $lang !== 'en') {
$guideFile = $rootDir . '/guide/en/' . (empty($pagePath) ? 'index.md' : $pagePath . '.md');
}
// Handle 404
if (!file_exists($guideFile)) {
http_response_code(404);
return [
'title' => 'Handleiding niet gevonden',
'content' => '<p>De gevraagde handleiding is niet gevonden.</p>',
'layout' => 'content',
'guide_breadcrumbs' => [],
'guide_lang' => $lang,
'guide_page' => $pagePath,
];
}
$content = file_get_contents($guideFile);
$result = $this->parseMarkdown($content, $guideFile);
// Build breadcrumbs
$guideBase = '/' . $lang . '/guide';
$breadcrumbs = [['title' => $this->t('manual'), 'url' => $guideBase]];
if (!empty($pagePath)) {
$parts = explode('/', $pagePath);
$buildPath = '';
foreach ($parts as $part) {
$buildPath .= ($buildPath ? '/' : '') . $part;
$breadcrumbs[] = [
'title' => str_replace('-', ' ', ucfirst($part)),
'url' => $guideBase . '?page=' . $buildPath,
];
}
}
$result['title'] = ($result['metadata']['title'] ?? $this->t('manual')) . ' - CodePress CMS';
$result['layout'] = 'guide';
$result['metadata']['plugins'] = 'Navigation';
$result['guide_breadcrumbs'] = $breadcrumbs;
$result['guide_lang'] = $lang;
$result['guide_page'] = $pagePath;
return $result;
}
/**
* Genereer een directory-listingpagina.
*
* Toont de titel van de map en een lijst met alle mappen en bestanden
* daarin. Leest eventuele layout/plugins-metadata uit een index.md in
* dezelfde map.
*
* @since 2.6.4
*
* @param string $pagePath Relative path to directory.
* @param string $dirPath Absolute path to directory.
* @return array Directory listing page data.
*/
private function getDirectoryListing($pagePath, $dirPath) {
// Get the directory name from the path, not from a potential file
$pathParts = explode('/', $pagePath);
$dirName = end($pathParts);
$title = $this->formatDisplayName($dirName) ?: 'Home';
$content = '<h1>' . htmlspecialchars($title) . '</h1>';
$result = [
'title' => $title,
'content' => $content
];
if (!is_dir($dirPath)) {
return [
'title' => $title,
'content' => $content . '<p>Directory not found.</p>'
];
}
$items = scandir($dirPath);
sort($items);
$hasContent = false;
// Collect all items
$allItems = [];
foreach ($items as $item) {
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $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)) {
// Each file keeps its own link (incl. extension) so every type
// stays viewable at the frontend even when names overlap.
$extractedTitle = $this->extractPageTitle($itemPath);
$fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME));
$icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' :
(pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark');
$allItems[] = [
'name' => $fileTitle,
'path' => $relativePath,
'url' => $this->buildUrl($relativePath),
'icon' => $icon,
'type' => 'file'
];
}
}
// Display all items in a single column
if (!empty($allItems)) {
$content .= '<div class="list-group">';
foreach ($allItems as $item) {
$content .= '<a href="' . htmlspecialchars($item['url']) . '" class="list-group-item list-group-item-action d-flex align-items-center">';
$content .= '<i class="bi ' . $item['icon'] . ' me-3"></i>';
$content .= '<span>' . htmlspecialchars($item['name']) . '</span>';
$content .= '</a>';
}
$content .= '</div>';
$hasContent = true;
}
if (!$hasContent) {
$content .= '<p>' . $this->t('directory_empty') . '.</p>';
}
// Check for index.md in this directory for layout/plugins metadata
$indexFile = $dirPath . '/index.md';
$layout = 'full_content';
$metadata = [];
if (file_exists($indexFile)) {
$indexContent = file_get_contents($indexFile);
$parsed = $this->parseMetadata($indexContent);
$metadata = $parsed['metadata'];
$layout = $metadata['layout'] ?? 'full_content';
}
return [
'title' => $title,
'content' => $content,
'layout' => $layout,
'metadata' => $metadata,
];
}
/**
* Geef de content van de 404-foutpagina.
*
* @since 2.6.4
* @return array 404 page data.
*/
private function getError404() {
$staticFile = __DIR__ . '/../../../admin/static/404.html';
$body = file_exists($staticFile)
? file_get_contents($staticFile)
: '<h1>404 - ' . $this->t('page_not_found') . '</h1><p>' . $this->t('page_not_found_text') . '</p>';
return [
'title' => $this->t('page_not_found'),
'content' => $body,
'layout' => 'full_content',
'metadata' => [],
'is_404' => true,
];
}
/**
* Geef de opgebouwde menu-structuur terug.
*
* @since 2.6.4
* @return array Menu structure for navigation.
*/
public function getMenu() {
return $this->menu;
}
/**
* Render de volledige pagina met het actieve theme.
*
* Bepaalt pagina-data, vuurt plugin-acties af, stelt template-data samen
* (menu, sidebar, breadcrumbs, SEO, theme-assets) en echoot de
* gerenderde layout via ThemeManager.
*
* @since 2.6.4
* @return void
*/
public function render() {
$page = $this->getPage();
$this->pluginManager->doAction('onPageLoad', $page);
$this->pluginManager->doAction('onBeforeRender');
// Set 404 status for not-found pages (rendered through the theme)
if (!empty($page['is_404'])) {
http_response_code(404);
}
$menu = $this->getMenu();
// Get homepage title
$homepageTitle = $this->getHomepageTitle();
// Get sidebar content from plugins (filtered per-page if specified in frontmatter)
$allowedPlugins = null;
if (!empty($page['metadata']['plugins'])) {
$allowedPlugins = array_map('trim', explode(',', $page['metadata']['plugins']));
}
$sidebarContent = $this->pluginManager->getSidebarContent($allowedPlugins);
// Get layout from page metadata
$layout = $page['layout'] ?? 'sidebar-content';
// Determine if sidebar toggle should be shown
$isGuidePage = isset($_GET['guide']) || ($page['layout'] ?? '') === 'guide';
$hasSidebar = $layout !== 'content' && !empty(trim($sidebarContent));
$breadcrumb = $this->generateBreadcrumb($hasSidebar, $isGuidePage ? $page : null);
// Prepare template data
$templateData = [
'site_title' => $this->config['site_title'],
'page_title' => htmlspecialchars($this->pluginManager->applyFilters('onTitleFilter', $page['title'])),
'content' => $this->pluginManager->applyFilters('onContentFilter', $this->processContent($page['content'])),
'sidebar_content' => $sidebarContent,
'layout' => $layout,
'page_metadata' => $page['metadata'] ?? [],
'search_query' => isset($_GET['search']) ? htmlspecialchars($_GET['search']) : '',
'menu' => $this->renderMenu($menu),
'breadcrumb' => $breadcrumb,
'default_page' => $this->getEffectiveDefaultPage(),
'homepage' => $this->getEffectiveDefaultPage(),
'homepage_title' => $homepageTitle,
'is_homepage' => (!isset($_GET['page']) || $_GET['page'] === $this->getEffectiveDefaultPage()),
'home_active_class' => (!isset($_GET['page']) || $_GET['page'] === $this->getEffectiveDefaultPage()) ? 'active' : '',
'is_guide_page' => isset($_GET['guide']),
'lang_switch_url' => '',
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
'author_git' => $this->config['author']['git'] ?? '',
// Per-page author metadata (from frontmatter, if present)
'page_author_name' => htmlspecialchars($page['metadata']['author_name'] ?? ''),
'page_author_email' => htmlspecialchars($page['metadata']['author_email'] ?? ''),
'page_created' => htmlspecialchars($page['metadata']['created'] ?? ''),
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
'block_ai_bots' => !empty($this->config['security']['block_ai_bots']),
'block_search_engines' => !empty($this->config['security']['block_search_engines']),
'cms_version' => ($this->config['show_version'] ?? true) && isset($this->config['version_info']) ? $this->config['version_info']['version'] : '',
// Theme colors
'header_color' => $this->config['theme']['header_color'] ?? '#0d6efd',
'header_font_color' => $this->config['theme']['header_font_color'] ?? '#ffffff',
'header_height' => $this->config['theme']['header_height'] ?? '56',
'navigation_color' => $this->config['theme']['navigation_color'] ?? '#f8f9fa',
'navigation_font_color' => $this->config['theme']['navigation_font_color'] ?? '#000000',
'nav_height' => $this->config['theme']['nav_height'] ?? '42',
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
// Language
'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage),
'current_page' => $this->sanitizePageParam($_GET['page'] ?? $this->getEffectiveDefaultPage()),
'available_langs' => array_map(function($lang) {
$lang['is_current'] = $lang['code'] === $this->currentLanguage;
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
// Sanitize page parameter to prevent XSS
$page = $this->sanitizePageParam($page);
$lang['url'] = '/' . $lang['code'] . ($page !== $this->getEffectiveDefaultPage() ? '/' . $page : '');
return $lang;
}, $this->getAvailableLanguages()),
// Translations
't_home' => $this->t('home'),
't_search' => $this->t('search'),
't_search_placeholder' => $this->t('search_placeholder'),
't_search_button' => $this->t('search_button'),
't_welcome' => $this->t('welcome'),
't_created' => $this->t('created'),
't_modified' => $this->t('modified'),
't_author' => $this->t('author'),
't_manual' => $this->t('manual'),
't_no_content' => $this->t('no_content'),
't_no_results' => $this->t('no_results'),
't_results_found' => $this->t('results_found'),
't_breadcrumb_home' => $this->t('breadcrumb_home'),
't_file_details' => $this->t('file_details'),
't_guide' => $this->t('guide'),
't_powered_by' => $this->t('powered_by'),
't_directory_empty' => $this->t('directory_empty'),
't_page_not_found' => $this->t('page_not_found'),
't_page_not_found_text' => $this->t('page_not_found_text'),
't_mappen' => $this->t('mappen'),
't_paginas' => $this->t('paginas'),
't_author_website' => $this->t('author_website'),
't_author_git' => $this->t('author_git')
];
// File info for footer
if (isset($page['file_info'])) {
$templateData['created'] = htmlspecialchars($page['file_info']['created']);
$templateData['modified'] = htmlspecialchars($page['file_info']['modified']);
$templateData['show_created'] = !empty($page['file_info']['show_created']);
$templateData['file_info_block'] = true;
} else {
$templateData['created'] = '';
$templateData['modified'] = '';
$templateData['show_created'] = false;
$templateData['file_info_block'] = false;
}
// Check if content exists for guide link
$hasContent = !$this->isContentDirEmpty();
$templateData['has_content'] = $hasContent;
// Don't show site title link on guide page
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
// Pass guide-specific data to template
if (isset($page['guide_breadcrumbs'])) {
$templateData['guide_breadcrumbs'] = $page['guide_breadcrumbs'];
$templateData['guide_lang'] = $page['guide_lang'] ?? $this->currentLanguage;
$templateData['guide_page'] = $page['guide_page'] ?? '';
}
// Map legacy frontmatter layout values to theme template keys
$layoutKey = $this->mapLayoutToThemeKey($layout);
// Add theme asset URLs to template data
$themeManager = new ThemeManager($this->config);
$templateData['theme_title'] = $themeManager->getTitle();
$templateData['theme_css_url'] = $themeManager->getCssUrl();
$templateData['theme_js_url'] = $themeManager->getJsUrl();
$templateData['theme_config'] = $themeManager->getConfig();
$templateData['theme_base_url'] = '/themes/' . basename($themeManager->getThemeDir());
$templateData['theme_css_files'] = $themeManager->getCssFiles();
$templateData['theme_js_files'] = $themeManager->getJsFiles();
$templateData['theme_favicon'] = $themeManager->getFaviconUrl();
// Plugin CSS (loaded after theme CSS so theme can override)
$templateData['plugin_css_urls'] = $this->pluginManager->getPluginCssUrls();
// Render the page through the active theme
$renderedLayout = $themeManager->render($layoutKey, $templateData);
echo $renderedLayout;
$this->pluginManager->doAction('onAfterRender', $renderedLayout);
}
/**
* Genereer breadcrumb-navigatie-HTML.
*
* Ondersteunt guide-pagina's, zoekpagina's en reguliere content-pagina's
* met subdirectory's. Toont optioneel een sidebar-toggle-knop.
*
* @since 2.6.4
*
* @param bool $hasSidebar Whether sidebar content exists and should show toggle.
* @param array|null $guidePage Guide page data (when on a guide page).
* @return string Breadcrumb HTML.
*/
public function generateBreadcrumb($hasSidebar = true, $guidePage = null) {
// Sidebar toggle button (shown before home icon in breadcrumb)
$sidebarToggle = '';
if ($hasSidebar) {
$sidebarToggle = '<li class="breadcrumb-item sidebar-toggle-item"><button type="button" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle Sidebar" aria-label="Toggle Sidebar" aria-expanded="true"><i class="bi bi-layout-sidebar-inset"></i></button></li>';
}
// Guide page breadcrumb: Home > Handleiding > [page parts]
if ($guidePage !== null) {
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
$breadcrumb .= $sidebarToggle;
$breadcrumb .= '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>';
$breadcrumb .= '<li class="breadcrumb-item"> > </li>';
$guidePagePath = $guidePage['guide_page'] ?? '';
$guideLang = $guidePage['guide_lang'] ?? $this->currentLanguage;
$guideBase = '/' . $guideLang . '/guide';
if (empty($guidePagePath)) {
$breadcrumb .= '<li class="breadcrumb-item active">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</li>';
} else {
$breadcrumb .= '<li class="breadcrumb-item"><a href="' . htmlspecialchars($guideBase, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</a></li>';
$parts = explode('/', $guidePagePath);
$buildPath = '';
foreach ($parts as $i => $part) {
$buildPath .= ($buildPath ? '/' : '') . $part;
$title = htmlspecialchars(str_replace('-', ' ', ucfirst($part)), ENT_QUOTES, 'UTF-8');
$url = htmlspecialchars($guideBase . '?page=' . $buildPath, ENT_QUOTES, 'UTF-8');
if ($i === count($parts) - 1) {
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>';
} else {
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="' . $url . '">' . $title . '</a></li>';
}
}
}
$breadcrumb .= '</ol></nav>';
return $breadcrumb;
}
if (isset($_GET['search'])) {
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li><li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $this->t('search') . '</li></ol></nav>';
}
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
$page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
$page = preg_replace('/\.[^.]+$/', '', $page);
$isHomepage = ($page === $this->getEffectiveDefaultPage());
$homeUrl = '/' . $this->currentLanguage;
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
$breadcrumb .= $sidebarToggle;
// Home icon: clickable link to language root (always, unless we're on the root itself)
$breadcrumb .= '<li class="breadcrumb-item"><a href="' . $homeUrl . '"><i class="bi bi-house"></i></a></li>';
// Split page path and build breadcrumb items (handles subdirectories dynamically)
$parts = explode('/', $page);
$currentPath = '';
foreach ($parts as $i => $part) {
$currentPath .= ($currentPath ? '/' : '') . $part;
$title = htmlspecialchars(ucfirst($part), ENT_QUOTES, 'UTF-8');
$safePath = htmlspecialchars($currentPath, ENT_QUOTES, 'UTF-8');
if ($i === count($parts) - 1) {
// Last part - active page
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>';
} else {
// Parent directory - clickable link with separator
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '/' . $safePath . '">' . $title . '</a></li>';
}
}
$breadcrumb .= '</ol></nav>';
return $breadcrumb;
}
/**
* Render menu-HTML met ondersteuning voor dropdowns.
*
* Bestanden op root-niveau worden als tabs getoond; bestanden in mappen
* als dropdown-items. Mappen zonder kinderen worden als uitgevinkt getoond.
*
* @since 2.6.4
*
* @param array $items Menu items to render.
* @param int $level Current nesting level.
* @return string Rendered menu HTML.
*/
private function renderMenu($items, $level = 0) {
$html = '';
foreach ($items as $item) {
if ($item['type'] === 'folder') {
$hasChildren = !empty($item['children']);
if ($hasChildren) {
$folderId = 'folder-' . str_replace('/', '-', $item['path']);
$isExpanded = $this->folderContainsActivePage($item['children']);
if ($level === 0) {
// Root level folders - Bootstrap dropdown
$html .= '<li class="nav-item dropdown">';
$html .= '<a class="nav-link dropdown-toggle" href="#" id="' . $folderId . '" role="button" data-bs-toggle="dropdown" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">';
$html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-down"></i>';
$html .= '</a>';
$html .= '<ul class="dropdown-menu" aria-labelledby="' . $folderId . '">';
$html .= $this->renderMenu($item['children'], $level + 1);
$html .= '</ul>';
$html .= '</li>';
} else {
// Nested folders - CSS hover submenu (unlimited depth)
$html .= '<li class="dropdown-submenu' . ($isExpanded ? ' show' : '') . '">';
$html .= '<a class="dropdown-item dropdown-toggle" href="#" id="' . $folderId . '" role="button" aria-expanded="' . ($isExpanded ? 'true' : 'false') . '">';
$html .= htmlspecialchars($item['title']) . ' <i class="bi bi-chevron-right"></i>';
$html .= '</a>';
$html .= '<ul class="dropdown-menu' . ($isExpanded ? ' show' : '') . '" aria-labelledby="' . $folderId . '">';
$html .= $this->renderMenu($item['children'], $level + 1);
$html .= '</ul>';
$html .= '</li>';
}
} else {
if ($level === 0) {
$html .= '<li class="nav-item">';
$html .= '<span class="nav-link text-muted">' . htmlspecialchars($item['title']) . '</span>';
$html .= '</li>';
} else {
$html .= '<li><span class="dropdown-item text-muted">' . htmlspecialchars($item['title']) . '</span></li>';
}
}
} else {
// Show files in root as tabs, files in folders as dropdown items
if ($level === 0) {
$active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : '';
$html .= '<li class="nav-item">';
$html .= '<a class="nav-link ' . $active . '" href="' . htmlspecialchars($item['url']) . '">';
$html .= htmlspecialchars($item['title']);
$html .= '</a>';
$html .= '</li>';
} else {
// Show files in dropdown menus
$active = (isset($_GET['page']) && $_GET['page'] === $item['path']) ? 'active' : '';
$html .= '<li><a class="dropdown-item ' . $active . '" href="' . htmlspecialchars($item['url']) . '">';
$html .= htmlspecialchars($item['title']);
$html .= '</a></li>';
}
}
}
return $html;
}
/**
* Vertaal een frontmatter layout-waarde naar een theme template-sleutel.
*
* Legacy waarden worden omgezet naar de nieuwe theme-keys. Onbekende
* waarden worden doorgegeven zodat ThemeManager kan terugvallen op
* default_layout.
*
* @since 2.6.4
*
* @param string $layout Layout value from page metadata.
* @return string Theme template key.
*/
private function mapLayoutToThemeKey(string $layout): string {
return match ($layout) {
'content' => 'full_content',
'sidebar-content', 'content-sidebar' => 'left_sidebar',
'content-sidebar-reverse' => 'right_sidebar',
'sidebar' => 'custom1',
default => $layout,
};
}
/**
* Detecteer automatisch de eerste beschikbare content-pagina.
*
* Scant de content-directory naar het eerste .md/.php/.html-bestand
* (met voorkeur voor bestanden zonder taal-prefix) en geeft de page-key terug.
*
* @since 2.6.4
*
* @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'];
}
/**
* Detecteer de meest recent gewijzigde of aangemaakte pagina.
*
* @since 2.6.4
*
* @return string Page key that was most recently modified or created.
*/
private function detectNewestPage(): string
{
$contentDir = $this->config['content_dir'];
$realContentDir = realpath($contentDir);
if (!$realContentDir || !is_dir($realContentDir)) return 'index';
$availableLangs = array_keys($this->getAvailableLanguages());
if (empty($availableLangs)) {
$availableLangs = ['nl', 'en'];
}
$langRegex = implode('|', array_map('preg_quote', $availableLangs));
$candidates = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($realContentDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) continue;
$ext = strtolower($fileInfo->getExtension());
if (!in_array($ext, ['md', 'php', 'html'])) continue;
$filePath = $fileInfo->getRealPath();
$relative = substr($filePath, strlen($realContentDir) + 1);
// Skip hidden files/directories (starting with . or -)
$parts = explode('/', str_replace('\\', '/', $relative));
$skip = false;
foreach ($parts as $p) {
if ($p !== '' && ($p[0] === '.' || $p[0] === '-')) {
$skip = true;
break;
}
}
if ($skip) continue;
// Strip extension
$base = preg_replace('/\.(md|php|html)$/i', '', $relative);
// Handle language prefix e.g. nl.test -> test, or dir/en.page -> dir/page
$filename = basename($base);
$dirPrefix = dirname($base);
$dirPrefix = ($dirPrefix === '.' || $dirPrefix === '') ? '' : $dirPrefix . '/';
if (preg_match('/^(' . $langRegex . ')\.(.+)$/i', $filename, $m)) {
$pageKey = $dirPrefix . $m[2];
} else {
$pageKey = $base;
}
// If it ends with /index, e.g. folder/index -> folder
if (str_ends_with($pageKey, '/index')) {
$pageKey = substr($pageKey, 0, -6);
}
$mtime = $fileInfo->getMTime();
$candidates[] = [
'key' => $pageKey ?: 'index',
'mtime' => $mtime
];
}
if (empty($candidates)) return 'index';
usort($candidates, function ($a, $b) {
return $b['mtime'] <=> $a['mtime'];
});
return $candidates[0]['key'];
}
/**
* Geef de titel van de homepagina terug.
*
* @since 2.6.4
* @return string Homepage title.
*/
private function getHomepageTitle() {
// Use a generic "Home" label instead of the page name
// to avoid duplication with the navigation menu
return $this->t('home');
}
/**
* Controleer of een map de actieve pagina bevat.
*
* @since 2.6.4
*
* @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;
}
/**
* Verwerk content-HTML vóór renderen.
*
* Herschrijft legacy `-/assets/`-shortcuts en `<img src>`/`<a href>`-
* attributen die wijzen naar bestanden in de content-directory naar het
* `/-media/`-endpoint, zodat deze bestanden (die buiten de webroot
* staan) correct worden geserveerd. Absolute URLs, reeds herschreven
* `/-media/`- en `/-assets/`-URLs, theme/plugin/admin-asset-URLs,
* data:-/mailto:-URLs, fragment-URLs en clean taal-voorziene
* pagina-routes blijven ongemoeid.
*
* @since 2.6.4
*
* @param string $content Content HTML to process.
* @return string Verwerkte content met herschreven media-URLs.
*/
private function processContent(string $content): string
{
// Legacy rewrite for the old -/assets/ shortcut
$content = str_replace('-/assets/', '/-assets/', $content);
// Rewrite <img src="..."> and <a href="..."> that point to files in the
// content directory so they are served via the /-media/ endpoint. The
// content/ directory lives outside the webroot, so raw URLs like
// "test.svg", "/content/test.svg" or "sub/test.svg" would otherwise 404.
//
// Skipped (left untouched):
// - absolute URLs (http://, https://, //)
// - already rewritten /-media/ and /-assets/ URLs
// - theme/plugin asset URLs (/themes/..., /plugins/..., /admin/...)
// - data: and mailto: URLs
// - fragment-only URLs (#anchor)
// - clean language-prefixed page routes (/<lang>/<page>) — these are
// valid internal page URLs (used by autoLinkPageTitles) and must not
// be rewritten to the /-media/ endpoint
$langCodes = array_keys($this->getAvailableLanguages());
$langPattern = empty($langCodes) ? '' : '|/(?:' . implode('|', array_map('preg_quote', $langCodes)) . ')/';
$skipPattern = '~^(?:[a-z][a-z0-9+.\-]*:|//|/themes/|/plugins/|/admin/|/-media/|/-assets/|data:|mailto:' . $langPattern . ')~i';
$content = preg_replace_callback(
'~(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*")([^"]+)(")~i',
function ($m) use ($skipPattern) {
$url = $m[2];
// Leave absolute, already-rewritten, asset, data:, anchor and
// language-prefixed page-route URLs alone
if (preg_match($skipPattern, $url)) {
return $m[0];
}
// Strip a leading /content/ prefix if present, then prefix with /-media/
$clean = preg_replace('~^/content/~', '', $url);
// Strip a leading slash so it becomes a clean /-media/<path>
$clean = ltrim($clean, '/');
return $m[1] . '/-media/' . $clean . $m[3];
},
$content
);
return $content;
}
}