Files
E.Noorlander 20adea7544 v2.6.0: Content backup/git versioning, plugin type system, docs update
New features:
- ContentBackup class with ZIP backup/restore and git versioning
- Admin backup & restore page (content-backup.twig) with git init/commit/log/restore
- Plugin type system: system (blue) vs content (green) with visual badges
- PluginAPIInterface + AdminPluginAPI for plugin architecture
- Essential plugin flag (cannot edit/deactivate/delete)

Improvements:
- Consolidated enabled_plugins config (removed plugins.enabled)
- Removed Analytics/Logging toggles from admin config page
- Fixed Dashboard plugin Twig comments rendered as text
- Updated 20 guide files (NL+EN): configuratie, plugins, plugin-development,
  core-classes, theme-json, layouts, scss-styling, admin-beheerder, nieuw-thema, architectuur
- Improved accessibility test script (grep -E, min/max checks)

Cleanup:
- Removed unused classes: ARIAComponents, AccessibilityManager, ContentSecurityPolicy, etc.
- Removed vendor packages: mustache/mustache, php-mqtt/client
- Removed old templates: logs.twig, statistics.twig (now plugins)
- Moved language files to language/ directory

Tests:
- Pentest: 30/30 passed, 0 vulnerabilities
- WCAG 2.1 AA: 25/25 passed, 100% compliance
2026-08-15 19:21:04 +02:00

292 lines
9.4 KiB
PHP

<?php
class Navigation
{
private array $config;
private ?PluginAPIInterface $api = null;
public function __construct()
{
$this->config = [
'title' => 'Navigatie',
'viewable' => true,
'type' => 'content',
];
}
public function setAPI(PluginAPIInterface $api): void
{
$this->api = $api;
}
public function getConfig(): array
{
return $this->config;
}
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Get the path to this plugin's CSS file (relative to web root).
*/
public function getCssUrl(): string
{
return '/plugins/Navigation/assets/css/navigation.css';
}
/**
* Generate sidebar navigation.
* Detects whether we're on a guide page or content page and builds nav accordingly.
*/
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);
}
/**
* Build navigation for the guide section.
*/
private function buildGuideNav(string $lang, string $currentPage, bool $isAdmin): string
{
$guideRoot = 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);
}
/**
* Build navigation for the content section.
*/
private function buildContentNav(string $lang, string $currentPage): string
{
$contentRoot = 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);
}
/**
* Recursively build navigation from a directory structure.
*
* @param string $dir Current directory
* @param string $relPath Relative path from root
* @param string $currentPage Current page path
* @param bool $isAdmin Whether this is admin context
* @param string $mode 'guide' or 'content'
* @param string $lang Current language
* @param int $depth Nesting depth (0 = top level)
*/
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);
}
/**
* Build the URL for a page.
*/
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);
}
/**
* Check if a nav item is the current page.
*/
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;
}
/**
* Get available language codes.
*/
private function getLanguageCodes(): array
{
return ['nl', 'en', 'de', 'fr'];
}
/**
* Format a slug into a readable title.
*/
private function formatTitle(string $slug): string
{
$title = str_replace('-', ' ', $slug);
$title = ucfirst($title);
return $title;
}
/**
* Extract the H1 title from a markdown/PHP/HTML file.
* Falls back to formatTitle() if no H1 is found.
*/
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);
}
/**
* Render the navigation items as HTML.
*
* @param array $items Navigation items
* @param int $depth Nesting depth (0 = top level)
*/
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;
}
}