386 lines
12 KiB
PHP
386 lines
12 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Navigation plugin: bouwt zijbalk-navigatie voor content en guide.
|
|
*
|
|
* Content-plugin die de navigatie opbouwt op basis van de mappenstructuur,
|
|
* met ondersteuning voor guide-pagina's en reguliere content-pagina's.
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
class Navigation
|
|
{
|
|
/**
|
|
* Plugin-configuratie.
|
|
*
|
|
* @since 2.6.4
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $config;
|
|
|
|
/**
|
|
* Plugin-API instantie voor communicatie met de CMS-core.
|
|
*
|
|
* @since 2.6.4
|
|
* @var PluginAPIInterface|null
|
|
*/
|
|
private ?PluginAPIInterface $api = null;
|
|
|
|
/**
|
|
* Initialiseert de plugin met standaardconfiguratie.
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
public function __construct()
|
|
{
|
|
$this->config = [
|
|
'title' => 'Navigatie',
|
|
'viewable' => true,
|
|
'type' => 'content',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Stelt de Plugin-API instantie in.
|
|
*
|
|
* @since 2.6.4
|
|
* @param PluginAPIInterface $api De Plugin-API instantie.
|
|
* @return void
|
|
*/
|
|
public function setAPI(PluginAPIInterface $api): void
|
|
{
|
|
$this->api = $api;
|
|
}
|
|
|
|
/**
|
|
* Geeft de plugin-configuratie terug.
|
|
*
|
|
* @since 2.6.4
|
|
* @return array<string, mixed> Plugin-configuratie.
|
|
*/
|
|
public function getConfig(): array
|
|
{
|
|
return $this->config;
|
|
}
|
|
|
|
/**
|
|
* Stelt de plugin-configuratie in door deze te mergen met standaardwaarden.
|
|
*
|
|
* @since 2.6.4
|
|
* @param array<string, mixed> $config De configuratie die samengevoegd moet worden.
|
|
* @return void
|
|
*/
|
|
public function setConfig(array $config): void
|
|
{
|
|
$this->config = array_merge($this->config, $config);
|
|
}
|
|
|
|
/**
|
|
* Geeft het pad naar het CSS-bestand van deze plugin (relatief ten opzichte van de webroot).
|
|
*
|
|
* @since 2.6.4
|
|
* @return string Het CSS-URL-pad.
|
|
*/
|
|
public function getCssUrl(): string
|
|
{
|
|
return '/plugins/Navigation/assets/css/navigation.css';
|
|
}
|
|
|
|
/**
|
|
* Genereert de zijbalk-navigatie.
|
|
*
|
|
* Detecteert of het om een guide-pagina of een content-pagina gaat en
|
|
* bouwt de navigatie dienovereenkomstig op.
|
|
*
|
|
* @since 2.6.4
|
|
* @return string De HTML-navigatie, of een lege string in admin-context.
|
|
*/
|
|
public function getSidebarContent(): string
|
|
{
|
|
$isGuide = isset($_GET['guide']) || ($_GET['page'] ?? '') === 'guide';
|
|
$isAdminGuide = isset($_GET['route']) && $_GET['route'] === 'guide';
|
|
$isAdmin = isset($_GET['route']);
|
|
$lang = $_GET['lang'] ?? ($this->api ? $this->api->getCurrentLanguage() : 'nl');
|
|
$currentPage = $_GET['page'] ?? '';
|
|
|
|
if ($isGuide || $isAdminGuide) {
|
|
return $this->buildGuideNav($lang, $currentPage, $isAdminGuide);
|
|
}
|
|
|
|
// Content navigation
|
|
if ($isAdmin) {
|
|
return '';
|
|
}
|
|
return $this->buildContentNav($lang, $currentPage);
|
|
}
|
|
|
|
/**
|
|
* Bouwt de navigatie voor de guide-sectie.
|
|
*
|
|
* @since 2.6.4
|
|
* @param string $lang De huidige taalcode.
|
|
* @param string $currentPage De huidige pagina-pad.
|
|
* @param bool $isAdmin Of dit in admin-context is.
|
|
* @return string De opgebouwde HTML-navigatie, of een lege string.
|
|
*/
|
|
private function buildGuideNav(string $lang, string $currentPage, bool $isAdmin): string
|
|
{
|
|
$guideRoot = ($this->api ? $this->api->getProjectRoot() : dirname(__DIR__, 2)) . '/guide/' . $lang;
|
|
if (!is_dir($guideRoot)) {
|
|
return '';
|
|
}
|
|
|
|
// Fix: /nl/guide sets page='guide', treat as empty
|
|
if ($currentPage === 'guide') {
|
|
$currentPage = '';
|
|
}
|
|
|
|
return $this->buildNav($guideRoot, '', $currentPage, $isAdmin, 'guide', $lang);
|
|
}
|
|
|
|
/**
|
|
* Bouwt de navigatie voor de content-sectie.
|
|
*
|
|
* @since 2.6.4
|
|
* @param string $lang De huidige taalcode.
|
|
* @param string $currentPage De huidige pagina-pad.
|
|
* @return string De opgebouwde HTML-navigatie, of een lege string.
|
|
*/
|
|
private function buildContentNav(string $lang, string $currentPage): string
|
|
{
|
|
$contentRoot = $this->api ? $this->api->getContentDir() : dirname(__DIR__, 2) . '/content';
|
|
if (!is_dir($contentRoot)) {
|
|
return '';
|
|
}
|
|
|
|
// Normalize current page: remove extension
|
|
$currentPage = preg_replace('/\.(md|php|html)$/', '', $currentPage);
|
|
|
|
return $this->buildNav($contentRoot, '', $currentPage, false, 'content', $lang);
|
|
}
|
|
|
|
/**
|
|
* Bouwt recursief navigatie op vanuit een mapstructuur.
|
|
*
|
|
* @since 2.6.4
|
|
* @param string $dir Huidige map.
|
|
* @param string $relPath Relatief pad vanaf de root.
|
|
* @param string $currentPage Huidige pagina-pad.
|
|
* @param bool $isAdmin Of dit in admin-context is.
|
|
* @param string $mode Modus: 'guide' of 'content'.
|
|
* @param string $lang Huidige taalcode.
|
|
* @param int $depth Nesting-diepte (0 = topniveau). Standaard 0.
|
|
* @return string De opgebouwde HTML-navigatie.
|
|
*/
|
|
private function buildNav(string $dir, string $relPath, string $currentPage, bool $isAdmin, string $mode, string $lang, int $depth = 0): string
|
|
{
|
|
$items = [];
|
|
$entries = scandir($dir);
|
|
// Sort alphabetically (case-insensitive)
|
|
natcasesort($entries);
|
|
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..' || $entry === 'assets' || $entry === 'index.md') {
|
|
continue;
|
|
}
|
|
// Skip hidden files/folders (starting with -)
|
|
if ($entry[0] === '-' || $entry[0] === '.') {
|
|
continue;
|
|
}
|
|
// Skip non-content files in content mode
|
|
if ($mode === 'content' && is_file($dir . '/' . $entry)) {
|
|
$ext = pathinfo($entry, PATHINFO_EXTENSION);
|
|
if (!in_array($ext, ['md', 'php', 'html'])) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$fullPath = $dir . '/' . $entry;
|
|
$itemRelPath = $relPath ? $relPath . '/' . $entry : $entry;
|
|
|
|
if (is_dir($fullPath)) {
|
|
$parentMd = $dir . '/' . $entry . '.md';
|
|
$title = file_exists($parentMd) ? $this->getTitleFromFile($parentMd) : $this->formatTitle($entry);
|
|
|
|
$pageRef = $relPath ? $relPath . '/' . $entry : $entry;
|
|
$url = $this->buildUrl($pageRef, $lang, $isAdmin, $mode);
|
|
$children = $this->buildNav($fullPath, $itemRelPath, $currentPage, $isAdmin, $mode, $lang, $depth + 1);
|
|
|
|
// Only show directories that have children or a parent .md
|
|
if (empty($children) && !file_exists($parentMd)) {
|
|
continue;
|
|
}
|
|
|
|
$items[] = [
|
|
'title' => $title,
|
|
'url' => $url,
|
|
'active' => $this->isActive($pageRef, $currentPage, $mode),
|
|
'children' => $children,
|
|
'has_children' => !empty($children),
|
|
];
|
|
} else {
|
|
$ext = pathinfo($entry, PATHINFO_EXTENSION);
|
|
if (!in_array($ext, ['md', 'php', 'html'])) {
|
|
continue;
|
|
}
|
|
|
|
$slug = pathinfo($entry, PATHINFO_FILENAME);
|
|
// Skip language-prefixed files (e.g. nl.pagina.md)
|
|
$langCodes = $this->getLanguageCodes();
|
|
foreach ($langCodes as $lc) {
|
|
if (strpos($slug, $lc . '.') === 0) {
|
|
$slug = substr($slug, strlen($lc) + 1);
|
|
break;
|
|
}
|
|
}
|
|
// Skip if this is a parent .md that has a matching directory
|
|
if (is_dir($dir . '/' . $slug)) {
|
|
continue;
|
|
}
|
|
|
|
$pageRef = $relPath ? $relPath . '/' . $slug : $slug;
|
|
$url = $this->buildUrl($pageRef, $lang, $isAdmin, $mode);
|
|
|
|
$items[] = [
|
|
'title' => $this->getTitleFromFile($fullPath),
|
|
'url' => $url,
|
|
'active' => $this->isActive($pageRef, $currentPage, $mode),
|
|
'children' => '',
|
|
'has_children' => false,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $this->renderNav($items, $depth);
|
|
}
|
|
|
|
/**
|
|
* Bouwt de URL voor een pagina-referentie.
|
|
*
|
|
* @since 2.6.4
|
|
* @param string $pageRef De pagina-referentie.
|
|
* @param string $lang De huidige taalcode.
|
|
* @param bool $isAdmin Of dit in admin-context is.
|
|
* @param string $mode Modus: 'guide' of 'content'.
|
|
* @return string De opgebouwde URL.
|
|
*/
|
|
private function buildUrl(string $pageRef, string $lang, bool $isAdmin, string $mode): string
|
|
{
|
|
if ($mode === 'guide') {
|
|
if ($isAdmin) {
|
|
return '/admin/guide?page=' . urlencode($pageRef) . '&lang=' . urlencode($lang);
|
|
}
|
|
return '/' . urlencode($lang) . '/guide?page=' . urlencode($pageRef);
|
|
}
|
|
// Content mode
|
|
return '/' . urlencode($lang) . '/' . urlencode($pageRef);
|
|
}
|
|
|
|
/**
|
|
* Controleert of een navigatie-item de huidige pagina is.
|
|
*
|
|
* @since 2.6.4
|
|
* @param string $pageRef De pagina-referentie van het item.
|
|
* @param string $currentPage Het huidige pagina-pad.
|
|
* @param string $mode Modus: 'guide' of 'content'.
|
|
* @return bool True als het item actief is, anders false.
|
|
*/
|
|
private function isActive(string $pageRef, string $currentPage, string $mode): bool
|
|
{
|
|
if ($mode === 'guide' && $currentPage === 'guide') {
|
|
$currentPage = '';
|
|
}
|
|
$currentPage = preg_replace('/\.(md|php|html)$/', '', $currentPage);
|
|
return $pageRef === $currentPage;
|
|
}
|
|
|
|
/**
|
|
* Geeft de beschikbare taalcodes terug.
|
|
*
|
|
* @since 2.6.4
|
|
* @return array<int, string> Lijst met taalcodes.
|
|
*/
|
|
private function getLanguageCodes(): array
|
|
{
|
|
return ['nl', 'en', 'de', 'fr'];
|
|
}
|
|
|
|
/**
|
|
* Formateert een slug naar een leesbare titel.
|
|
*
|
|
* @since 2.6.4
|
|
* @param string $slug De te formatteren slug.
|
|
* @return string De geformatteerde titel.
|
|
*/
|
|
private function formatTitle(string $slug): string
|
|
{
|
|
$title = str_replace('-', ' ', $slug);
|
|
$title = ucfirst($title);
|
|
return $title;
|
|
}
|
|
|
|
/**
|
|
* Haalt de H1-titel uit een markdown-, PHP- of HTML-bestand.
|
|
*
|
|
* Valt terug op formatTitle() wanneer geen H1 gevonden is.
|
|
*
|
|
* @since 2.6.4
|
|
* @param string $filePath Pad naar het bestand.
|
|
* @return string De gevonden titel, of een lege string.
|
|
*/
|
|
private function getTitleFromFile(string $filePath): string
|
|
{
|
|
if (!file_exists($filePath)) {
|
|
return '';
|
|
}
|
|
|
|
$content = file_get_contents($filePath);
|
|
// Match first H1: # Title
|
|
if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
|
|
return trim($matches[1]);
|
|
}
|
|
|
|
$slug = pathinfo($filePath, PATHINFO_FILENAME);
|
|
return $this->formatTitle($slug);
|
|
}
|
|
|
|
/**
|
|
* Rendert de navigatie-items als HTML.
|
|
*
|
|
* @since 2.6.4
|
|
* @param array $items Navigatie-items.
|
|
* @param int $depth Nesting-diepte (0 = topniveau). Standaard 0.
|
|
* @return string De gegenereerde HTML.
|
|
*/
|
|
private function renderNav(array $items, int $depth = 0): string
|
|
{
|
|
if (empty($items)) {
|
|
return '';
|
|
}
|
|
|
|
$subClass = $depth > 0 ? ' nav-sub' : '';
|
|
$html = '<ul class="list-unstyled nav-plugin' . $subClass . '">';
|
|
foreach ($items as $item) {
|
|
$activeClass = $item['active'] ? ' active' : '';
|
|
$parentClass = $item['has_children'] ? ' nav-parent' : '';
|
|
$html .= '<li class="nav-plugin-item">';
|
|
$html .= '<a href="' . htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') . '" class="nav-plugin-link' . $activeClass . $parentClass . '">';
|
|
if ($item['has_children']) {
|
|
$html .= '<i class="bi bi-folder-fill nav-plugin-icon"></i> ';
|
|
} else {
|
|
$html .= '<i class="bi bi-file-earmark nav-plugin-icon"></i> ';
|
|
}
|
|
$html .= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
|
|
$html .= '</a>';
|
|
|
|
if ($item['has_children']) {
|
|
$html .= $item['children'];
|
|
}
|
|
|
|
$html .= '</li>';
|
|
}
|
|
$html .= '</ul>';
|
|
|
|
return $html;
|
|
}
|
|
} |