- Bug: dashboard toonde 0 content (AdminPluginAPI::getContentDir() gaf relatief pad terug zonder normalisatie) - Dynamische pad-resolutie: PluginAPIInterface uitgebreid met getProjectRoot/getContentDir/getPluginsDir/getVersionInfo; CMSAPI en AdminPluginAPI implementeren deze universeel - public/index.php media-serving gebruikt $config['content_dir'] i.p.v. hardcoded /content - Navigation en Logs plugins halen paden via de API i.p.v. hardcoded dirname(__DIR__) - WordPress-stijl docblocks toegevoegd voor alle classes, methods, properties en functies (~450 docblocks, @since 2.6.5) - Security: hardcoded plaintext-wachtwoord 'admin' verwijderd uit AdminAuth.php; bij eerste installatie wordt een cryptografisch veilig wachtwoord gegenereerd (random_bytes, 16 tekens) en eenmalig op het inlogscherm getoond - Security: git-geschiedenis schoongemaakt (admin.json, admin.json.example, admin-console/config/admin.json verwijderd uit alle commits; filter-branch over alle branches + tags, gc --prune --aggressive) - README.md, README.en.md, AGENTS.md bijgewerkt - Test-scripts bijgewerkt naar clean-URL structuur + actuele ARIA-waarden - Versie verhoogd naar 2.6.5 - Tests: pentest 29/29, WCAG 25/25, functioneel 16/16, enhanced 25/25
2117 lines
82 KiB
PHP
2117 lines
82 KiB
PHP
<?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.5
|
||
*/
|
||
class CodePressCMS {
|
||
/**
|
||
* Configuratie-array met alle site-instellingen.
|
||
*
|
||
* @since 2.6.5
|
||
* @var array
|
||
*/
|
||
public $config;
|
||
|
||
/**
|
||
* Actieve taalcode van het huidige verzoek (bijv. 'nl' of 'en').
|
||
*
|
||
* @since 2.6.5
|
||
* @var string
|
||
*/
|
||
public $currentLanguage;
|
||
|
||
/**
|
||
* Resultaten van de laatst uitgevoerde zoekopdracht.
|
||
*
|
||
* @since 2.6.5
|
||
* @var array
|
||
*/
|
||
public $searchResults = [];
|
||
|
||
/**
|
||
* Opgebouwde menu-structuur vanuit de content-directory.
|
||
*
|
||
* @since 2.6.5
|
||
* @var array
|
||
*/
|
||
private $menu = [];
|
||
|
||
/**
|
||
* Effectieve standaardpagina die op de taal-root URL wordt geserveerd.
|
||
*
|
||
* Wordt lazy geinitialiseerd in getEffectiveDefaultPage().
|
||
*
|
||
* @since 2.6.5
|
||
* @var string|null
|
||
*/
|
||
private ?string $effectiveDefaultPage = null;
|
||
|
||
/**
|
||
* Geladen vertalingen voor de actieve taal.
|
||
*
|
||
* @since 2.6.5
|
||
* @var array
|
||
*/
|
||
private $translations = [];
|
||
|
||
/**
|
||
* PluginManager-instance die plugins en hooks beheert.
|
||
*
|
||
* @since 2.6.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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: {: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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
* @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.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
* @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.5
|
||
* @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.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
* @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.5
|
||
*
|
||
* @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.5
|
||
*
|
||
* @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;
|
||
}
|
||
}
|