Merge development v2.6.0 into main

Resolved conflicts by taking development (v2.6.0) version for all files.
Removed statistics.twig (replaced by Statistics plugin).
This commit is contained in:
2026-08-15 19:32:47 +02:00
207 changed files with 3736 additions and 23879 deletions
-390
View File
@@ -1,390 +0,0 @@
<?php
/**
* ARIAComponents - WCAG 2.1 AA Compliant Component Library
*
* Features:
* - Full ARIA support for all components
* - Keyboard navigation
* - Screen reader optimization
* - Focus management
* - WCAG 2.1 AA compliance
*/
class ARIAComponents {
/**
* Create accessible button with full ARIA support
*
* @param string $text Button text
* @param array $options Button options
* @return string Accessible button HTML
*/
public static function createAccessibleButton($text, $options = []) {
$id = $options['id'] ?? 'btn-' . uniqid();
$class = $options['class'] ?? 'btn btn-primary';
$ariaLabel = $options['aria-label'] ?? $text;
$ariaPressed = $options['aria-pressed'] ?? 'false';
$ariaExpanded = $options['aria-expanded'] ?? 'false';
$ariaControls = $options['aria-controls'] ?? '';
$disabled = $options['disabled'] ?? false;
$type = $options['type'] ?? 'button';
$attributes = [
'id="' . $id . '"',
'type="' . $type . '"',
'class="' . $class . '"',
'tabindex="0"',
'role="button"',
'aria-label="' . htmlspecialchars($ariaLabel, ENT_QUOTES, 'UTF-8') . '"',
'aria-pressed="' . $ariaPressed . '"',
'aria-expanded="' . $ariaExpanded . '"'
];
if ($ariaControls) {
$attributes[] = 'aria-controls="' . $ariaControls . '"';
}
if ($disabled) {
$attributes[] = 'disabled';
$attributes[] = 'aria-disabled="true"';
}
return '<button ' . implode(' ', $attributes) . '>' . htmlspecialchars($text, ENT_QUOTES, 'UTF-8') . '</button>';
}
/**
* Create accessible navigation with full ARIA support
*
* @param array $menu Menu structure
* @param array $options Navigation options
* @return string Accessible navigation HTML
*/
public static function createAccessibleNavigation($menu, $options = []) {
$id = $options['id'] ?? 'main-navigation';
$label = $options['aria-label'] ?? 'Hoofdmenu';
$orientation = $options['orientation'] ?? 'horizontal';
$html = '<nav id="' . $id . '" role="navigation" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '">';
$html .= '<ul role="menubar" aria-orientation="' . $orientation . '">';
foreach ($menu as $index => $item) {
$html .= self::createNavigationItem($item, $index);
}
$html .= '</ul></nav>';
return $html;
}
/**
* Create navigation item with ARIA support
*
* @param array $item Menu item
* @param int $index Item index
* @return string Navigation item HTML
*/
private static function createNavigationItem($item, $index) {
$hasChildren = isset($item['children']) && !empty($item['children']);
$itemId = 'nav-item-' . $index;
if ($hasChildren) {
$html = '<li role="none">';
$html .= '<a href="' . htmlspecialchars($item['url'] ?? '#', ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'id="' . $itemId . '" ';
$html .= 'role="menuitem" ';
$html .= 'aria-haspopup="true" ';
$html .= 'aria-expanded="false" ';
$html .= 'tabindex="0" ';
$html .= 'class="nav-link dropdown-toggle">';
$html .= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
$html .= '<span class="sr-only"> submenu</span>';
$html .= '</a>';
$html .= '<ul role="menu" aria-labelledby="' . $itemId . '" class="dropdown-menu">';
foreach ($item['children'] as $childIndex => $child) {
$html .= self::createNavigationItem($child, $index . '-' . $childIndex);
}
$html .= '</ul></li>';
} else {
$html = '<li role="none">';
$html .= '<a href="' . htmlspecialchars($item['url'] ?? '#', ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'role="menuitem" ';
$html .= 'tabindex="0" ';
$html .= 'class="nav-link">';
$html .= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
$html .= '</a></li>';
}
return $html;
}
/**
* Create accessible form with full ARIA support
*
* @param array $fields Form fields
* @param array $options Form options
* @return string Accessible form HTML
*/
public static function createAccessibleForm($fields, $options = []) {
$id = $options['id'] ?? 'form-' . uniqid();
$method = $options['method'] ?? 'POST';
$action = $options['action'] ?? '';
$label = $options['aria-label'] ?? 'Formulier';
$html = '<form id="' . $id . '" method="' . $method . '" action="' . htmlspecialchars($action, ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'role="form" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'novalidate>';
foreach ($fields as $index => $field) {
$html .= self::createFormField($field, $index);
}
$html .= '</form>';
return $html;
}
/**
* Create accessible form field with full ARIA support
*
* @param array $field Field configuration
* @param int $index Field index
* @return string Form field HTML
*/
private static function createFormField($field, $index) {
$id = $field['id'] ?? 'field-' . $index;
$type = $field['type'] ?? 'text';
$label = $field['label'] ?? 'Veld ' . ($index + 1);
$required = $field['required'] ?? false;
$help = $field['help'] ?? '';
$error = $field['error'] ?? '';
$html = '<div class="form-group">';
// Label with required indicator
$html .= '<label for="' . $id . '" class="form-label">';
$html .= htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
if ($required) {
$html .= '<span class="required" aria-label="verplicht">*</span>';
}
$html .= '</label>';
// Input with ARIA attributes
$inputAttributes = [
'type="' . $type . '"',
'id="' . $id . '"',
'name="' . htmlspecialchars($field['name'] ?? $id, ENT_QUOTES, 'UTF-8') . '"',
'class="form-control"',
'tabindex="0"',
'aria-describedby="' . $id . '-help' . ($error ? ' ' . $id . '-error' : '') . '"',
'aria-required="' . ($required ? 'true' : 'false') . '"'
];
if ($error) {
$inputAttributes[] = 'aria-invalid="true"';
$inputAttributes[] = 'aria-errormessage="' . $id . '-error"';
}
if (isset($field['placeholder'])) {
$inputAttributes[] = 'placeholder="' . htmlspecialchars($field['placeholder'], ENT_QUOTES, 'UTF-8') . '"';
}
$html .= '<input ' . implode(' ', $inputAttributes) . ' />';
// Help text
if ($help) {
$html .= '<div id="' . $id . '-help" class="form-text" role="note">';
$html .= htmlspecialchars($help, ENT_QUOTES, 'UTF-8');
$html .= '</div>';
}
// Error message
if ($error) {
$html .= '<div id="' . $id . '-error" class="form-text text-danger" role="alert" aria-live="polite">';
$html .= htmlspecialchars($error, ENT_QUOTES, 'UTF-8');
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
/**
* Create accessible search form
*
* @param array $options Search options
* @return string Accessible search form HTML
*/
public static function createAccessibleSearch($options = []) {
$id = $options['id'] ?? 'search-form';
$placeholder = $options['placeholder'] ?? 'Zoeken...';
$buttonText = $options['button-text'] ?? 'Zoeken';
$label = $options['aria-label'] ?? 'Zoeken op de website';
$html = '<form id="' . $id . '" role="search" aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '" method="GET" action="">';
$html .= '<div class="input-group">';
// Search input
$html .= '<input type="search" name="search" id="search-input" ';
$html .= 'class="form-control" ';
$html .= 'placeholder="' . htmlspecialchars($placeholder, ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'aria-label="' . htmlspecialchars($placeholder, ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'tabindex="0" ';
$html .= 'autocomplete="off" ';
$html .= 'spellcheck="false" />';
// Search button
$html .= self::createAccessibleButton($buttonText, [
'id' => 'search-button',
'class' => 'btn btn-outline-secondary',
'aria-label' => 'Zoekopdracht uitvoeren',
'type' => 'submit'
]);
$html .= '</div></form>';
return $html;
}
/**
* Create accessible breadcrumb navigation
*
* @param array $breadcrumbs Breadcrumb items
* @param array $options Breadcrumb options
* @return string Accessible breadcrumb HTML
*/
public static function createAccessibleBreadcrumb($breadcrumbs, $options = []) {
$label = $options['aria-label'] ?? 'Broodkruimelnavigatie';
$html = '<nav aria-label="' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '">';
$html .= '<ol class="breadcrumb">';
foreach ($breadcrumbs as $index => $crumb) {
$isLast = $index === count($breadcrumbs) - 1;
$html .= '<li class="breadcrumb-item">';
if ($isLast) {
$html .= '<span aria-current="page">' . htmlspecialchars($crumb['title'], ENT_QUOTES, 'UTF-8') . '</span>';
} else {
$html .= '<a href="' . htmlspecialchars($crumb['url'] ?? '#', ENT_QUOTES, 'UTF-8') . '" tabindex="0">';
$html .= htmlspecialchars($crumb['title'], ENT_QUOTES, 'UTF-8');
$html .= '</a>';
}
$html .= '</li>';
}
$html .= '</ol></nav>';
return $html;
}
/**
* Create accessible skip links
*
* @param array $targets Skip targets
* @return string Skip links HTML
*/
public static function createSkipLinks($targets = []) {
$defaultTargets = [
['id' => 'main-content', 'text' => 'Skip to main content'],
['id' => 'navigation', 'text' => 'Skip to navigation'],
['id' => 'search', 'text' => 'Skip to search']
];
$targets = array_merge($defaultTargets, $targets);
$html = '<div class="skip-links">';
foreach ($targets as $target) {
$html .= '<a href="#' . htmlspecialchars($target['id'], ENT_QUOTES, 'UTF-8') . '" ';
$html .= 'class="skip-link" tabindex="0">';
$html .= htmlspecialchars($target['text'], ENT_QUOTES, 'UTF-8');
$html .= '</a>';
}
$html .= '</div>';
return $html;
}
/**
* Create accessible modal dialog
*
* @param string $id Modal ID
* @param string $title Modal title
* @param string $content Modal content
* @param array $options Modal options
* @return string Accessible modal HTML
*/
public static function createAccessibleModal($id, $title, $content, $options = []) {
$label = $options['aria-label'] ?? $title;
$closeText = $options['close-text'] ?? 'Sluiten';
$html = '<div id="' . $id . '" class="modal" role="dialog" ';
$html .= 'aria-modal="true" aria-labelledby="' . $id . '-title" aria-hidden="true">';
$html .= '<div class="modal-dialog" role="document">';
$html .= '<div class="modal-content">';
// Header
$html .= '<div class="modal-header">';
$html .= '<h2 id="' . $id . '-title" class="modal-title">' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h2>';
$html .= self::createAccessibleButton($closeText, [
'class' => 'btn-close',
'aria-label' => 'Modal sluiten',
'data-bs-dismiss' => 'modal'
]);
$html .= '</div>';
// Body
$html .= '<div class="modal-body" role="document">';
$html .= $content;
$html .= '</div>';
$html .= '</div></div></div>';
return $html;
}
/**
* Create accessible alert/notice
*
* @param string $message Alert message
* @param string $type Alert type (info, success, warning, error)
* @param array $options Alert options
* @return string Accessible alert HTML
*/
public static function createAccessibleAlert($message, $type = 'info', $options = []) {
$id = $options['id'] ?? 'alert-' . uniqid();
$dismissible = $options['dismissible'] ?? false;
$role = $options['role'] ?? 'alert';
$classMap = [
'info' => 'alert-info',
'success' => 'alert-success',
'warning' => 'alert-warning',
'error' => 'alert-danger'
];
$html = '<div id="' . $id . '" class="alert ' . ($classMap[$type] ?? 'alert-info') . '" ';
$html .= 'role="' . $role . '" aria-live="polite" aria-atomic="true">';
$html .= htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
if ($dismissible) {
$html .= self::createAccessibleButton('×', [
'class' => 'btn-close',
'aria-label' => 'Melding sluiten',
'data-bs-dismiss' => 'alert'
]);
}
$html .= '</div>';
return $html;
}
}
-747
View File
@@ -1,747 +0,0 @@
<?php
/**
* AccessibilityManager - Dynamic WCAG 2.1 AA Compliance Manager
*
* Features:
* - Dynamic accessibility adaptation
* - User preference detection
* - Real-time accessibility adjustments
* - High contrast mode support
* - Font size adaptation
* - Focus management
* - WCAG 2.1 AA compliance monitoring
*/
class AccessibilityManager {
private $config;
private $userPreferences;
private $accessibilityMode;
private $highContrastMode;
private $largeTextMode;
private $reducedMotionMode;
private $keyboardOnlyMode;
public function __construct($config = []) {
$this->config = $config;
$this->userPreferences = $this->detectUserPreferences();
$this->accessibilityMode = $this->determineAccessibilityMode();
$this->initializeAccessibilityFeatures();
}
/**
* Detect user accessibility preferences
*
* @return array User preferences
*/
private function detectUserPreferences() {
$preferences = [
'high_contrast' => $this->detectHighContrastPreference(),
'large_text' => $this->detectLargeTextPreference(),
'reduced_motion' => $this->detectReducedMotionPreference(),
'keyboard_only' => $this->detectKeyboardOnlyPreference(),
'screen_reader' => $this->detectScreenReaderPreference(),
'voice_control' => $this->detectVoiceControlPreference(),
'color_blind' => $this->detectColorBlindPreference(),
'dyslexia_friendly' => $this->detectDyslexiaPreference()
];
// Store preferences in session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['accessibility_preferences'] = $preferences;
return $preferences;
}
/**
* Detect high contrast preference
*
* @return bool True if high contrast preferred
*/
private function detectHighContrastPreference() {
// Check browser preferences
if (isset($_SERVER['HTTP_SEC_CH_PREFERS_COLOR_SCHEME'])) {
$prefers = $_SERVER['HTTP_SEC_CH_PREFERS_COLOR_SCHEME'];
return strpos($prefers, 'high') !== false || strpos($prefers, 'dark') !== false;
}
// Check user agent for high contrast indicators
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
return strpos($userAgent, 'high-contrast') !== false ||
strpos($userAgent, 'contrast') !== false;
}
/**
* Detect large text preference
*
* @return bool True if large text preferred
*/
private function detectLargeTextPreference() {
// Check browser font size preference
if (isset($_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_DATA'])) {
return strpos($_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_DATA'], 'reduce') !== false;
}
// Check session preference
if (isset($_SESSION['accessibility_preferences']['large_text'])) {
return $_SESSION['accessibility_preferences']['large_text'];
}
// Check URL parameter
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'large-text') !== false) {
return true;
}
return false;
}
/**
* Detect reduced motion preference
*
* @return bool True if reduced motion preferred
*/
private function detectReducedMotionPreference() {
// Check browser preference
if (isset($_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_MOTION'])) {
return $_SERVER['HTTP_SEC_CH_PREFERS_REDUCED_MOTION'] === 'reduce';
}
// Check CSS media query support
return false; // Would need client-side detection
}
/**
* Detect keyboard-only preference
*
* @return bool True if keyboard-only user
*/
private function detectKeyboardOnlyPreference() {
// Check session for keyboard navigation detection
if (isset($_SESSION['keyboard_navigation_detected'])) {
return $_SESSION['keyboard_navigation_detected'];
}
// Check URL parameter
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'keyboard') !== false) {
return true;
}
return false;
}
/**
* Detect screen reader preference
*
* @return bool True if screen reader detected
*/
private function detectScreenReaderPreference() {
// Check user agent for screen readers
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$screenReaders = [
'JAWS', 'NVDA', 'VoiceOver', 'TalkBack', 'ChromeVox',
'Window-Eyes', 'System Access To Go', 'ZoomText',
'Dragon NaturallySpeaking', 'Kurzweil 3000'
];
foreach ($screenReaders as $reader) {
if (strpos($userAgent, $reader) !== false) {
return true;
}
}
return false;
}
/**
* Detect voice control preference
*
* @return bool True if voice control preferred
*/
private function detectVoiceControlPreference() {
// Check URL parameter
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'voice') !== false) {
return true;
}
// Check session preference
if (isset($_SESSION['accessibility_preferences']['voice_control'])) {
return $_SESSION['accessibility_preferences']['voice_control'];
}
return false;
}
/**
* Detect color blind preference
*
* @return bool True if color blind adaptation needed
*/
private function detectColorBlindPreference() {
// Check URL parameter
if (isset($_GET['accessibility'])) {
$accessibility = $_GET['accessibility'];
return strpos($accessibility, 'colorblind') !== false ||
strpos($accessibility, 'protanopia') !== false ||
strpos($accessibility, 'deuteranopia') !== false ||
strpos($accessibility, 'tritanopia') !== false;
}
return false;
}
/**
* Detect dyslexia-friendly preference
*
* @return bool True if dyslexia-friendly mode preferred
*/
private function detectDyslexiaPreference() {
// Check URL parameter
if (isset($_GET['accessibility']) && strpos($_GET['accessibility'], 'dyslexia') !== false) {
return true;
}
return false;
}
/**
* Determine accessibility mode based on preferences
*
* @return string Accessibility mode
*/
private function determineAccessibilityMode() {
if ($this->userPreferences['screen_reader']) {
return 'screen-reader';
}
if ($this->userPreferences['keyboard_only']) {
return 'keyboard-only';
}
if ($this->userPreferences['voice_control']) {
return 'voice-control';
}
if ($this->userPreferences['high_contrast']) {
return 'high-contrast';
}
if ($this->userPreferences['large_text']) {
return 'large-text';
}
if ($this->userPreferences['color_blind']) {
return 'color-blind';
}
if ($this->userPreferences['dyslexia_friendly']) {
return 'dyslexia-friendly';
}
return 'standard';
}
/**
* Initialize accessibility features
*/
private function initializeAccessibilityFeatures() {
$this->highContrastMode = $this->userPreferences['high_contrast'];
$this->largeTextMode = $this->userPreferences['large_text'];
$this->reducedMotionMode = $this->userPreferences['reduced_motion'];
$this->keyboardOnlyMode = $this->userPreferences['keyboard_only'];
}
/**
* Generate accessibility CSS
*
* @return string Accessibility CSS
*/
public function generateAccessibilityCSS() {
$css = '';
// High contrast mode
if ($this->highContrastMode) {
$css .= $this->getHighContrastCSS();
}
// Large text mode
if ($this->largeTextMode) {
$css .= $this->getLargeTextCSS();
}
// Reduced motion mode
if ($this->reducedMotionMode) {
$css .= $this->getReducedMotionCSS();
}
// Keyboard-only mode
if ($this->keyboardOnlyMode) {
$css .= $this->getKeyboardOnlyCSS();
}
// Color blind mode
if ($this->userPreferences['color_blind']) {
$css .= $this->getColorBlindCSS();
}
// Dyslexia-friendly mode
if ($this->userPreferences['dyslexia_friendly']) {
$css .= $this->getDyslexiaFriendlyCSS();
}
return $css;
}
/**
* Get high contrast CSS
*
* @return string High contrast CSS
*/
private function getHighContrastCSS() {
return '
/* High Contrast Mode */
body {
background: #000000 !important;
color: #ffffff !important;
}
.btn, button {
background: #ffffff !important;
color: #000000 !important;
border: 2px solid #ffffff !important;
}
.btn-primary {
background: #0000ff !important;
color: #ffffff !important;
border: 2px solid #0000ff !important;
}
a {
color: #ffff00 !important;
text-decoration: underline !important;
}
a:hover, a:focus {
color: #ffffff !important;
background: #0000ff !important;
}
.card, .well {
background: #1a1a1a !important;
border: 1px solid #ffffff !important;
}
.form-control {
background: #000000 !important;
color: #ffffff !important;
border: 1px solid #ffffff !important;
}
.form-control:focus {
border-color: #ffff00 !important;
outline: 2px solid #ffff00 !important;
}
img {
filter: contrast(1.5) !important;
}
';
}
/**
* Get large text CSS
*
* @return string Large text CSS
*/
private function getLargeTextCSS() {
return '
/* Large Text Mode */
body {
font-size: 120% !important;
line-height: 1.6 !important;
}
h1 { font-size: 2.2em !important; }
h2 { font-size: 1.8em !important; }
h3 { font-size: 1.6em !important; }
h4 { font-size: 1.4em !important; }
h5 { font-size: 1.2em !important; }
h6 { font-size: 1.1em !important; }
.btn, button {
font-size: 110% !important;
padding: 12px 24px !important;
min-height: 44px !important;
}
.form-control {
font-size: 110% !important;
padding: 12px !important;
min-height: 44px !important;
}
.nav-link {
font-size: 110% !important;
padding: 15px 20px !important;
}
';
}
/**
* Get reduced motion CSS
*
* @return string Reduced motion CSS
*/
private function getReducedMotionCSS() {
return '
/* Reduced Motion Mode */
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.carousel, .slider {
overflow: hidden !important;
}
.carousel-item, .slide {
transition: none !important;
}
';
}
/**
* Get keyboard-only CSS
*
* @return string Keyboard-only CSS
*/
private function getKeyboardOnlyCSS() {
return '
/* Keyboard-Only Mode */
*:focus {
outline: 3px solid #0056b3 !important;
outline-offset: 2px !important;
}
.btn:hover, button:hover {
background: inherit !important;
transform: none !important;
}
.dropdown:hover .dropdown-menu {
display: none !important;
}
.dropdown:focus-within .dropdown-menu {
display: block !important;
}
';
}
/**
* Get color blind CSS
*
* @return string Color blind CSS
*/
private function getColorBlindCSS() {
return '
/* Color Blind Mode */
.btn-success {
background: #0066cc !important;
border-color: #0066cc !important;
}
.btn-danger {
background: #ff6600 !important;
border-color: #ff6600 !important;
}
.btn-warning {
background: #666666 !important;
border-color: #666666 !important;
color: #ffffff !important;
}
.text-success {
color: #0066cc !important;
}
.text-danger {
color: #ff6600 !important;
}
.text-warning {
color: #666666 !important;
}
.alert-success {
background: #e6f2ff !important;
border-color: #0066cc !important;
color: #0066cc !important;
}
.alert-danger {
background: #ffe6cc !important;
border-color: #ff6600 !important;
color: #ff6600 !important;
}
';
}
/**
* Get dyslexia-friendly CSS
*
* @return string Dyslexia-friendly CSS
*/
private function getDyslexiaFriendlyCSS() {
return '
/* Dyslexia-Friendly Mode */
body {
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
letter-spacing: 0.1em !important;
line-height: 1.8 !important;
word-spacing: 0.1em !important;
}
h1, h2, h3, h4, h5, h6 {
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
letter-spacing: 0.05em !important;
}
p {
margin-bottom: 1.5em !important;
text-align: left !important;
}
.btn, button {
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
letter-spacing: 0.05em !important;
}
.form-control {
font-family: "OpenDyslexic", "Comic Sans MS", sans-serif !important;
letter-spacing: 0.05em !important;
}
';
}
/**
* Generate accessibility JavaScript
*
* @return string Accessibility JavaScript
*/
public function generateAccessibilityJS() {
$preferences = json_encode($this->userPreferences);
$mode = json_encode($this->accessibilityMode);
return "
// Accessibility Manager Initialization
window.accessibilityManager = {
preferences: {$preferences},
mode: {$mode},
init: function() {
this.setupEventListeners();
this.applyPreferences();
this.announceAccessibilityMode();
},
setupEventListeners: function() {
// Listen for preference changes
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 'a') {
this.showAccessibilityMenu();
}
});
},
applyPreferences: function() {
// Apply CSS classes based on preferences
if (this.preferences.high_contrast) {
document.body.classList.add('high-contrast');
}
if (this.preferences.large_text) {
document.body.classList.add('large-text');
}
if (this.preferences.reduced_motion) {
document.body.classList.add('reduced-motion');
}
if (this.preferences.keyboard_only) {
document.body.classList.add('keyboard-only');
}
if (this.preferences.color_blind) {
document.body.classList.add('color-blind');
}
if (this.preferences.dyslexia_friendly) {
document.body.classList.add('dyslexia-friendly');
}
},
announceAccessibilityMode: function() {
if (window.screenReaderOptimization) {
window.screenReaderOptimization.announceToScreenReader(
'Accessibility mode: ' + this.mode
);
}
},
showAccessibilityMenu: function() {
// Show accessibility preferences menu
const menu = document.createElement('div');
menu.id = 'accessibility-menu';
menu.className = 'accessibility-menu';
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', 'Accessibility Preferences');
menu.innerHTML = `
<h2>Accessibility Preferences</h2>
<div class='accessibility-options'>
<label>
<input type='checkbox' \${this.preferences.high_contrast ? 'checked' : ''}
onchange='accessibilityManager.togglePreference(\"high_contrast\", this.checked)'>
High Contrast
</label>
<label>
<input type='checkbox' \${this.preferences.large_text ? 'checked' : ''}
onchange='accessibilityManager.togglePreference(\"large_text\", this.checked)'>
Large Text
</label>
<label>
<input type='checkbox' \${this.preferences.reduced_motion ? 'checked' : ''}
onchange='accessibilityManager.togglePreference(\"reduced_motion\", this.checked)'>
Reduced Motion
</label>
<label>
<input type='checkbox' \${this.preferences.keyboard_only ? 'checked' : ''}
onchange='accessibilityManager.togglePreference(\"keyboard_only\", this.checked)'>
Keyboard Only
</label>
</div>
<button onclick='accessibilityManager.closeMenu()'>Close</button>
`;
document.body.appendChild(menu);
menu.focus();
},
togglePreference: function(preference, value) {
this.preferences[preference] = value;
this.applyPreferences();
// Save preference to server
fetch('/api/accessibility/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
preference: preference,
value: value
})
});
},
closeMenu: function() {
const menu = document.getElementById('accessibility-menu');
if (menu) {
document.body.removeChild(menu);
}
}
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
window.accessibilityManager.init();
});
";
}
/**
* Get accessibility menu HTML
*
* @return string Accessibility menu HTML
*/
public function getAccessibilityMenu() {
$menu = '<div id="accessibility-controls" class="accessibility-controls" role="toolbar" aria-label="Accessibility Controls">';
$menu .= '<button class="accessibility-toggle" aria-label="Accessibility Options" aria-expanded="false" aria-controls="accessibility-menu">';
$menu .= '<span class="sr-only">Accessibility Options</span>';
$menu .= '♿';
$menu .= '</button>';
$menu .= '<div id="accessibility-menu" class="accessibility-menu" role="menu" aria-hidden="true">';
$menu .= '<h3>Accessibility Options</h3>';
$menu .= '<div class="accessibility-option">';
$menu .= '<label>';
$menu .= '<input type="checkbox" id="high-contrast" ' . ($this->highContrastMode ? 'checked' : '') . '>';
$menu .= 'High Contrast';
$menu .= '</label>';
$menu .= '</div>';
$menu .= '<div class="accessibility-option">';
$menu .= '<label>';
$menu .= '<input type="checkbox" id="large-text" ' . ($this->largeTextMode ? 'checked' : '') . '>';
$menu .= 'Large Text';
$menu .= '</label>';
$menu .= '</div>';
$menu .= '<div class="accessibility-option">';
$menu .= '<label>';
$menu .= '<input type="checkbox" id="reduced-motion" ' . ($this->reducedMotionMode ? 'checked' : '') . '>';
$menu .= 'Reduced Motion';
$menu .= '</label>';
$menu .= '</div>';
$menu .= '<div class="accessibility-option">';
$menu .= '<label>';
$menu .= '<input type="checkbox" id="keyboard-only" ' . ($this->keyboardOnlyMode ? 'checked' : '') . '>';
$menu .= 'Keyboard Only';
$menu .= '</label>';
$menu .= '</div>';
$menu .= '</div>';
$menu .= '</div>';
return $menu;
}
/**
* Get accessibility report
*
* @return array Accessibility compliance report
*/
public function getAccessibilityReport() {
return [
'mode' => $this->accessibilityMode,
'preferences' => $this->userPreferences,
'features' => [
'high_contrast' => $this->highContrastMode,
'large_text' => $this->largeTextMode,
'reduced_motion' => $this->reducedMotionMode,
'keyboard_only' => $this->keyboardOnlyMode,
'screen_reader_support' => $this->userPreferences['screen_reader'],
'voice_control' => $this->userPreferences['voice_control'],
'color_blind_support' => $this->userPreferences['color_blind'],
'dyslexia_friendly' => $this->userPreferences['dyslexia_friendly']
],
'wcag_compliance' => [
'perceivable' => true,
'operable' => true,
'understandable' => true,
'robust' => true
],
'compliance_score' => 100,
'wcag_level' => 'AA'
];
}
}
-324
View File
@@ -1,324 +0,0 @@
<?php
/**
* AccessibleTemplate - WCAG 2.1 AA Compliant Template Engine
*
* Features:
* - Automatic ARIA label generation
* - Keyboard navigation support
* - Screen reader optimization
* - Dynamic accessibility adaptation
* - WCAG 2.1 AA compliance validation
*/
class AccessibleTemplate {
private $data;
private $ariaLabels = [];
private $keyboardNav = [];
private $screenReaderSupport = [];
private $wcagLevel = 'AA';
/**
* Render template with full accessibility support
*
* @param string $template Template content with placeholders
* @param array $data Data to populate template
* @return string Rendered accessible template
*/
public static function render($template, $data) {
$instance = new self();
$instance->data = $data;
return $instance->renderWithAccessibility($template);
}
/**
* Process template with accessibility enhancements
*
* @param string $template Template content
* @return string Processed accessible template
*/
private function renderWithAccessibility($template) {
// Handle partial includes first
$template = preg_replace_callback('/{{>([^}]+)}}/', [$this, 'replacePartial'], $template);
// Add accessibility enhancements
$template = $this->addAccessibilityAttributes($template);
// Handle conditional blocks with accessibility
$template = $this->processAccessibilityConditionals($template);
// Handle variable replacements with accessibility
$template = $this->replaceWithAccessibility($template);
// Validate WCAG compliance
$template = $this->validateWCAGCompliance($template);
return $template;
}
/**
* Add accessibility attributes to template
*
* @param string $template Template content
* @return string Enhanced template
*/
private function addAccessibilityAttributes($template) {
// Add ARIA landmarks
$template = $this->addARIALandmarks($template);
// Add keyboard navigation
$template = $this->addKeyboardNavigation($template);
// Add screen reader support
$template = $this->addScreenReaderSupport($template);
// Add skip links
$template = $this->addSkipLinks($template);
return $template;
}
/**
* Add ARIA landmarks for navigation
*
* @param string $template Template content
* @return string Template with ARIA landmarks
*/
private function addARIALandmarks($template) {
// Add navigation landmarks
$template = preg_replace('/<nav/', '<nav role="navigation" aria-label="Hoofdmenu"', $template);
// Add main landmark
$template = preg_replace('/<main/', '<main role="main" id="main-content" aria-label="Hoofdinhoud"', $template);
// Add header landmark
$template = preg_replace('/<header/', '<header role="banner" aria-label="Kop"', $template);
// Add footer landmark
$template = preg_replace('/<footer/', '<footer role="contentinfo" aria-label="Voettekst"', $template);
// Add search landmark
$template = preg_replace('/<form[^>]*search/', '<form role="search" aria-label="Zoeken"', $template);
return $template;
}
/**
* Add keyboard navigation support
*
* @param string $template Template content
* @return string Template with keyboard navigation
*/
private function addKeyboardNavigation($template) {
// Add tabindex to interactive elements
$template = preg_replace('/<a href/', '<a tabindex="0" href', $template);
// Add keyboard navigation to buttons
$template = preg_replace('/<button/', '<button tabindex="0"', $template);
// Add keyboard navigation to form inputs
$template = preg_replace('/<input/', '<input tabindex="0"', $template);
// Add aria-current for current page
if (isset($this->data['is_homepage']) && $this->data['is_homepage']) {
$template = preg_replace('/<a[^>]*>Home<\/a>/', '<a aria-current="page" class="active">Home</a>', $template);
}
return $template;
}
/**
* Add screen reader support
*
* @param string $template Template content
* @return string Template with screen reader support
*/
private function addScreenReaderSupport($template) {
// Add aria-live regions for dynamic content
$template = preg_replace('/<div[^>]*content/', '<div aria-live="polite" aria-atomic="true"', $template);
// Add aria-labels for images without alt text
$template = preg_replace('/<img(?![^>]*alt=)/', '<img alt="" role="img" aria-label="Afbeelding"', $template);
// Add aria-describedby for form help
$template = preg_replace('/<input[^>]*id="([^"]*)"[^>]*>/', '<input aria-describedby="$1-help"', $template);
// Add screen reader only text
$template = preg_replace('/class="active"/', 'class="active" aria-label="Huidige pagina"', $template);
return $template;
}
/**
* Add skip links for keyboard navigation
*
* @param string $template Template content
* @return string Template with skip links
*/
private function addSkipLinks($template) {
$skipLink = '<a href="#main-content" class="skip-link" tabindex="0">Skip to main content</a>';
// Add skip link after body tag
$template = preg_replace('/<body[^>]*>/', '$0' . $skipLink, $template);
return $template;
}
/**
* Process conditional blocks with accessibility
*
* @param string $template Template content
* @return string Processed template
*/
private function processAccessibilityConditionals($template) {
// Handle equal conditionals
$template = preg_replace_callback('/{{#equal\s+(\w+)\s+["\']([^"\']+)["\']}}(.*?){{\/equal}}/s', function($matches) {
$key = $matches[1];
$expectedValue = $matches[2];
$content = $matches[3];
$actualValue = $this->data[$key] ?? '';
return ($actualValue === $expectedValue) ? $this->addAccessibilityAttributes($content) : '';
}, $template);
// Handle standard conditionals with accessibility
foreach ($this->data as $key => $value) {
if (is_array($value)) {
// Handle array iteration
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match($pattern, $template, $matches)) {
$blockTemplate = $matches[1];
$replacement = '';
foreach ($value as $index => $item) {
$itemBlock = $this->addAccessibilityAttributes($blockTemplate);
if (is_array($item)) {
$tempTemplate = new self();
$tempTemplate->data = array_merge($this->data, $item, ['index' => $index]);
$replacement .= $tempTemplate->renderWithAccessibility($itemBlock);
} else {
$itemBlock = str_replace('{{.}}', htmlspecialchars($item, ENT_QUOTES, 'UTF-8'), $itemBlock);
$replacement .= $this->addAccessibilityAttributes($itemBlock);
}
}
$template = preg_replace($pattern, $replacement, $template);
}
} elseif ((is_string($value) && !empty($value)) || (is_bool($value) && $value === true)) {
// Handle truthy values
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match($pattern, $template, $matches)) {
$replacement = $this->addAccessibilityAttributes($matches[1]);
$template = preg_replace($pattern, $replacement, $template);
}
}
}
return $template;
}
/**
* Replace variables with accessibility support
*
* @param string $template Template content
* @return string Template with replaced variables
*/
private function replaceWithAccessibility($template) {
foreach ($this->data as $key => $value) {
// Handle triple braces for unescaped HTML content
if (strpos($template, '{{{' . $key . '}}}') !== false) {
$content = is_string($value) ? $value : print_r($value, true);
$content = $this->sanitizeForAccessibility($content);
$template = str_replace('{{{' . $key . '}}}', $content, $template);
}
// Handle double braces for escaped content
elseif (strpos($template, '{{' . $key . '}}') !== false) {
if (is_string($value)) {
$template = str_replace('{{' . $key . '}}', htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), $template);
} elseif (is_array($value)) {
$template = str_replace('{{' . $key . '}}', htmlspecialchars(json_encode($value), ENT_QUOTES, 'UTF-8'), $template);
} else {
$template = str_replace('{{' . $key . '}}', htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'), $template);
}
}
}
return $template;
}
/**
* Sanitize content for accessibility
*
* @param string $content Content to sanitize
* @return string Sanitized content
*/
private function sanitizeForAccessibility($content) {
// Remove potentially harmful content while preserving accessibility
$content = strip_tags($content, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><a><ul><ol><li><img><div><span><button><form><input><label><select><option><textarea>');
// Add ARIA attributes to preserved tags
$content = preg_replace('/<h([1-6])>/', '<h$1 role="heading" aria-level="$1">', $content);
return $content;
}
/**
* Validate WCAG compliance
*
* @param string $template Template content
* @return string Validated template
*/
private function validateWCAGCompliance($template) {
// Check for required ARIA landmarks
if (!preg_match('/role="navigation"/', $template)) {
$template = str_replace('<nav', '<nav role="navigation" aria-label="Hoofdmenu"', $template);
}
if (!preg_match('/role="main"/', $template)) {
$template = str_replace('<main', '<main role="main" id="main-content" aria-label="Hoofdinhoud"', $template);
}
// Check for skip links
if (!preg_match('/skip-link/', $template)) {
$skipLink = '<a href="#main-content" class="skip-link" tabindex="0">Skip to main content</a>';
$template = preg_replace('/<body[^>]*>/', '$0' . $skipLink, $template);
}
// Check for proper heading structure
if (!preg_match('/<h1/', $template)) {
$template = preg_replace('/<main[^>]*>/', '$0<h1 role="heading" aria-level="1">' . ($this->data['page_title'] ?? 'Content') . '</h1>', $template);
}
return $template;
}
/**
* Replace partial includes with data values
*
* @param array $matches Regex matches from preg_replace_callback
* @return string Replacement content
*/
private function replacePartial($matches) {
$partialName = $matches[1];
return isset($this->data[$partialName]) ? $this->data[$partialName] : $matches[0];
}
/**
* Generate accessibility report
*
* @return array Accessibility compliance report
*/
public function getAccessibilityReport() {
return [
'wcag_level' => $this->wcagLevel,
'aria_landmarks' => true,
'keyboard_navigation' => true,
'screen_reader_support' => true,
'skip_links' => true,
'color_contrast' => true,
'form_labels' => true,
'heading_structure' => true,
'focus_management' => true,
'compliance_score' => 100
];
}
}
-69
View File
@@ -1,69 +0,0 @@
<?php
class AssetManager {
private array $css = [];
private array $js = [];
public function __construct() {
// Constructor can be extended for future use
}
public function addCss(string $path): void {
$this->css[] = $path;
}
public function addJs(string $path): void {
$this->js[] = $path;
}
public function addBootstrapCss(): void {
$this->addCss('/assets/css/bootstrap.min.css');
$this->addCss('/assets/css/bootstrap-icons.css');
}
public function addBootstrapJs(): void {
$this->addJs('/assets/js/bootstrap.bundle.min.js');
}
public function addThemeCss(): void {
$this->addCss('/assets/css/style.css');
$this->addCss('/assets/css/mobile.css');
}
public function addAppJs(): void {
$this->addJs('/assets/js/app.js');
}
public function renderCss(): string {
$html = '';
foreach ($this->css as $path) {
$fullPath = $_SERVER['DOCUMENT_ROOT'] . $path;
$version = file_exists($fullPath) ? filemtime($fullPath) : time();
$html .= "<link rel=\"stylesheet\" href=\"$path?v=$version\">\n";
}
return $html;
}
public function renderJs(): string {
$html = '';
foreach ($this->js as $path) {
$fullPath = $_SERVER['DOCUMENT_ROOT'] . $path;
$version = file_exists($fullPath) ? filemtime($fullPath) : time();
$html .= "<script src=\"$path?v=$version\"></script>\n";
}
return $html;
}
public function getCssCount(): int {
return count($this->css);
}
public function getJsCount(): int {
return count($this->js);
}
public function clear(): void {
$this->css = [];
$this->js = [];
}
}
+94 -61
View File
@@ -146,26 +146,31 @@ class CodePressCMS {
}
/**
* Get all available languages from lang directory
* Get all available languages from the language directory
*
* Each language is a subdirectory under language/ containing a site.php file.
*
* @return array Available languages with their codes and names
*/
public function getAvailableLanguages() {
$langDir = __DIR__ . '/../../lang/';
$langDir = __DIR__ . '/../../../language/';
$languages = [];
if (!is_dir($langDir)) {
return $languages;
}
$files = scandir($langDir);
foreach ($files as $file) {
if (preg_match('/^([a-z]{2})\.php$/', $file, $matches)) {
$langCode = $matches[1];
$langFile = $langDir . $file;
$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($langFile)) {
$translations = include $langFile;
if (file_exists($siteFile)) {
$translations = include $siteFile;
if (!is_array($translations)) {
continue;
}
$languages[$langCode] = [
'code' => $langCode,
'name' => $translations['site_title'] ?? strtoupper($langCode),
@@ -179,22 +184,28 @@ class CodePressCMS {
}
/**
* Load translations for specified language
* Load site translations for specified language
*
* @param string $lang Language code
* @return array Translations array
*/
private function loadTranslations($lang) {
$langFile = __DIR__ . '/../../lang/' . $lang . '.php';
$langDir = __DIR__ . '/../../../language/';
$langFile = $langDir . $lang . '/site.php';
if (file_exists($langFile)) {
$translations = include $langFile;
return $translations;
if (is_array($translations)) {
return $translations;
}
}
// Fallback to default language
$defaultLang = $this->config['language']['default'] ?? 'nl';
$defaultLangFile = __DIR__ . '/../../lang/' . $defaultLang . '.php';
$defaultLangFile = $langDir . $defaultLang . '/site.php';
if (file_exists($defaultLangFile)) {
return include $defaultLangFile;
$translations = include $defaultLangFile;
if (is_array($translations)) {
return $translations;
}
}
// Return empty array if no translation found
return [];
@@ -208,19 +219,77 @@ class CodePressCMS {
*/
private function getNativeLanguageName($langCode) {
$names = [
'nl' => 'Nederlands',
'en' => 'English',
'fr' => 'Français',
'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',
'pt' => 'Português',
'ru' => 'Русский',
'zh' => '中文',
'ja' => '日本語',
'ar' => 'العربية'
'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);
}
@@ -1051,11 +1120,6 @@ class CodePressCMS {
return $result;
}
/**
* Detect user language from browser headers
*
* @return string Language code ('nl' or 'en')
*/
/**
* Generate directory listing page
*
@@ -1077,9 +1141,7 @@ class CodePressCMS {
'title' => $title,
'content' => $content
];
// Debug: ensure we're returning the right title
if (!is_dir($dirPath)) {
return [
'title' => $title,
@@ -1232,7 +1294,7 @@ class CodePressCMS {
'lang_switch_url' => '',
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
'author_git' => 'https://git.noorlander.info/E.Noorlander',
'author_git' => $this->config['author']['git'] ?? '',
'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']),
@@ -1509,35 +1571,6 @@ class CodePressCMS {
};
}
/**
* Determine content type for current page
*
* @param array $page Page data
* @return string Content type (markdown, php, html)
*/
private function getContentType($page) {
// Try to determine content type from page request
$pagePath = $_GET['page'] ?? $this->getEffectiveDefaultPage();
$pagePath = preg_replace('/\.[^.]+$/', '', $pagePath);
$filePath = $this->config['content_dir'] . '/' . $pagePath;
// Check for different file extensions
if (file_exists($filePath . '.md')) {
return 'markdown';
} elseif (file_exists($filePath . '.php')) {
return 'php';
} elseif (file_exists($filePath . '.html')) {
return 'html';
} elseif (file_exists($filePath)) {
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
return in_array($extension, ['md', 'php', 'html']) ? $extension : 'markdown';
}
// Default to markdown
return 'markdown';
}
/**
* Auto-detect the first available content page
*
@@ -1682,4 +1715,4 @@ class CodePressCMS {
{
return str_replace('-/assets/', '/-assets/', $content);
}
}// Guide fix: 1786349431
}
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
<?php
/**
* ContentBackup — backup and restore the content directory.
*
* Supports:
* - ZIP backup of the entire content directory
* - Restore from an uploaded ZIP file
* - Optional git-based versioning (init / commit / log / restore)
*/
class ContentBackup
{
private string $contentDir;
private string $projectRoot;
private bool $gitAvailable;
public function __construct(string $contentDir, string $projectRoot = '')
{
$this->contentDir = rtrim($contentDir, '/');
$this->projectRoot = $projectRoot !== '' ? rtrim($projectRoot, '/') : dirname(__DIR__, 3);
$this->gitAvailable = $this->detectGit();
}
/**
* Check whether git is available and the content dir is inside a git repo.
*/
public function isGitAvailable(): bool
{
return $this->gitAvailable;
}
/**
* Create a ZIP backup of the content directory.
*
* @param string $outputPath Where to write the ZIP file
* @return bool True on success
*/
public function createZipBackup(string $outputPath): bool
{
if (!class_exists('ZipArchive')) {
return false;
}
if (!is_dir($this->contentDir)) {
return false;
}
$zip = new ZipArchive();
if ($zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return false;
}
$this->addDirToZip($zip, $this->contentDir, 'content');
return $zip->close();
}
/**
* Restore content from a ZIP file.
*
* @param string $zipPath Path to the uploaded ZIP file
* @return array ['success' => bool, 'message' => string]
*/
public function restoreFromZip(string $zipPath): array
{
if (!class_exists('ZipArchive')) {
return ['success' => false, 'message' => 'ZipArchive niet beschikbaar.'];
}
if (!file_exists($zipPath)) {
return ['success' => false, 'message' => 'ZIP bestand niet gevonden.'];
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
return ['success' => false, 'message' => 'Kan ZIP bestand niet openen.'];
}
$tempDir = $this->projectRoot . '/var/tmp/restore_' . date('YmdHis');
if (!@mkdir($tempDir, 0755, true)) {
$zip->close();
return ['success' => false, 'message' => 'Kan tijdelijke map niet aanmaken.'];
}
$zip->extractTo($tempDir);
$zip->close();
// Determine source: either $tempDir/content/ or $tempDir/ itself
$sourceDir = is_dir($tempDir . '/content') ? $tempDir . '/content' : $tempDir;
// Backup current content before overwriting
$backupSubDir = $this->contentDir . '.bak.' . date('YmdHis');
if (is_dir($this->contentDir)) {
rename($this->contentDir, $backupSubDir);
}
if (!@mkdir($this->contentDir, 0755, true)) {
// Restore old content if mkdir fails
if (is_dir($backupSubDir)) {
rename($backupSubDir, $this->contentDir);
}
$this->removeDir($tempDir);
return ['success' => false, 'message' => 'Kan content map niet aanmaken.'];
}
$this->copyDir($sourceDir, $this->contentDir);
// Clean up temp dir
$this->removeDir($tempDir);
// Remove old backup (keep last one)
$this->cleanupOldBackups();
return ['success' => true, 'message' => 'Content succesvol hersteld.'];
}
/**
* Initialize git in the content directory.
*
* @return array ['success' => bool, 'message' => string]
*/
public function gitInit(): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
}
$result = $this->execGitCapture(['init'], $this->contentDir);
if ($result['exit'] !== 0) {
return ['success' => false, 'message' => 'Git init mislukt: ' . trim($result['error'])];
}
// Configure a default identity so commits work without global git config
$this->execGitCapture(['config', 'user.email', 'content@codepress.local'], $this->contentDir);
$this->execGitCapture(['config', 'user.name', 'CodePress CMS'], $this->contentDir);
return ['success' => true, 'message' => 'Git repository geïnitialiseerd in content/.'];
}
/**
* Commit all changes in the content directory.
*
* @param string $message Commit message
* @return array ['success' => bool, 'message' => string]
*/
public function gitCommit(string $message): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
}
if (!$this->hasGitRepo()) {
return ['success' => false, 'message' => 'Geen git repository in content/. Voer eerst git init uit.'];
}
$msg = trim($message) !== '' ? $message : 'Content update ' . date('Y-m-d H:i:s');
// Add all files
$addResult = $this->execGitCapture(['add', '-A'], $this->contentDir);
if ($addResult['exit'] !== 0) {
return ['success' => false, 'message' => 'Git add mislukt: ' . trim($addResult['error'])];
}
// Check if there are changes to commit
$statusResult = $this->execGitCapture(['status', '--porcelain'], $this->contentDir);
if (trim($statusResult['output']) === '') {
return ['success' => true, 'message' => 'Geen wijzigingen om te committen.'];
}
$commitResult = $this->execGitCapture(['commit', '-m', $msg], $this->contentDir);
if ($commitResult['exit'] !== 0) {
return ['success' => false, 'message' => 'Git commit mislukt: ' . trim($commitResult['error'])];
}
return ['success' => true, 'message' => 'Wijzigingen gecommit: ' . $msg];
}
/**
* Get the git log for the content directory.
*
* @param int $limit Maximum number of entries
* @return array ['success' => bool, 'commits' => array, 'message' => string]
*/
public function gitLog(int $limit = 20): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'commits' => [], 'message' => 'Git is niet beschikbaar.'];
}
if (!$this->hasGitRepo()) {
return ['success' => false, 'commits' => [], 'message' => 'Geen git repository.'];
}
$result = $this->execGitCapture(
['log', '--pretty=format:%H|%h|%ai|%s', '-' . (string)$limit],
$this->contentDir
);
if ($result['exit'] !== 0) {
// No commits yet is not an error in our context
if (str_contains($result['error'], 'does not have any commits')) {
return ['success' => true, 'commits' => [], 'message' => ''];
}
return ['success' => false, 'commits' => [], 'message' => 'Kan git log niet uitlezen: ' . trim($result['error'])];
}
$commits = [];
$lines = explode("\n", trim($result['output']));
if ($lines !== ['']) {
foreach ($lines as $line) {
$parts = explode('|', $line, 4);
if (count($parts) === 4) {
$commits[] = [
'hash' => $parts[0],
'short' => $parts[1],
'date' => $parts[2],
'message' => $parts[3],
];
}
}
}
return ['success' => true, 'commits' => $commits, 'message' => ''];
}
/**
* Restore content to a specific git commit.
*
* @param string $commitHash Commit hash to restore to
* @return array ['success' => bool, 'message' => string]
*/
public function gitRestore(string $commitHash): array
{
if (!$this->gitAvailable) {
return ['success' => false, 'message' => 'Git is niet beschikbaar.'];
}
if (!$this->hasGitRepo()) {
return ['success' => false, 'message' => 'Geen git repository.'];
}
$hash = preg_replace('/[^a-f0-9]/', '', $commitHash);
if ($hash === '') {
return ['success' => false, 'message' => 'Ongeldige commit hash.'];
}
// checkout <hash> -- . restores files from that commit into the working tree
$result = $this->execGitCapture(['checkout', $hash, '--', '.'], $this->contentDir);
if ($result['exit'] !== 0) {
return ['success' => false, 'message' => 'Git restore mislukt: ' . trim($result['error'])];
}
return ['success' => true, 'message' => 'Content hersteld naar commit ' . substr($hash, 0, 7)];
}
/**
* Check if the content directory has a git repository.
*/
public function hasGitRepo(): bool
{
return is_dir($this->contentDir . '/.git');
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
private function detectGit(): bool
{
$result = $this->execGitCapture(['--version']);
return $result['exit'] === 0;
}
/**
* Execute a git command and capture output, error, and exit code separately.
*
* @return array ['output' => string, 'error' => string, 'exit' => int]
*/
private function execGitCapture(array $args, ?string $cwd = null): array
{
$escaped = array_map('escapeshellarg', $args);
$command = 'git ' . implode(' ', $escaped);
$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
$process = proc_open($command, $descriptors, $pipes, $cwd ?? null);
if (!is_resource($process)) {
return ['output' => '', 'error' => 'Kan git proces niet starten.', 'exit' => -1];
}
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return [
'output' => $output !== false ? $output : '',
'error' => $error !== false ? $error : '',
'exit' => $exitCode,
];
}
private function addDirToZip(ZipArchive $zip, string $dir, string $prefix): void
{
$entries = scandir($dir);
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
$zipPath = $prefix . '/' . $entry;
if (is_dir($path)) {
$zip->addEmptyDir($zipPath);
$this->addDirToZip($zip, $path, $zipPath);
} elseif (is_file($path)) {
$zip->addFile($path, $zipPath);
}
}
}
private function copyDir(string $src, string $dst): void
{
if (!is_dir($src)) {
return;
}
if (!is_dir($dst)) {
@mkdir($dst, 0755, true);
}
$entries = scandir($src);
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$srcPath = $src . '/' . $entry;
$dstPath = $dst . '/' . $entry;
if (is_dir($srcPath)) {
$this->copyDir($srcPath, $dstPath);
} elseif (is_file($srcPath)) {
copy($srcPath, $dstPath);
}
}
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$entries = scandir($dir);
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
if (is_dir($path)) {
$this->removeDir($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
private function cleanupOldBackups(): void
{
$parentDir = dirname($this->contentDir);
$baseName = basename($this->contentDir);
$pattern = $parentDir . '/' . $baseName . '.bak.*';
$backups = glob($pattern);
if ($backups === false || count($backups) <= 1) {
return;
}
// Sort by modification time (oldest first)
usort($backups, function ($a, $b) {
return filemtime($a) - filemtime($b);
});
// Remove all but the last backup
$toRemove = array_slice($backups, 0, count($backups) - 1);
foreach ($toRemove as $backup) {
$this->removeDir($backup);
}
}
}
-50
View File
@@ -1,50 +0,0 @@
<?php
class ContentSecurityPolicy {
private array $directives = [];
public function __construct() {
$this->directives = [
'default-src' => ["'self'"],
'script-src' => ["'self'", "'unsafe-inline'"],
'style-src' => ["'self'", "'unsafe-inline'"],
'img-src' => ["'self'", 'data:', 'https:'],
'font-src' => ["'self'"],
'connect-src' => ["'self'"],
'media-src' => ["'self'"],
'object-src' => ["'none'"],
'frame-src' => ["'none'"],
'base-uri' => ["'self'"],
'form-action' => ["'self'"]
];
}
public function addDirective(string $name, array $values): void {
if (!isset($this->directives[$name])) {
$this->directives[$name] = [];
}
$this->directives[$name] = array_merge($this->directives[$name], $values);
}
public function removeDirective(string $name): void {
unset($this->directives[$name]);
}
public function setDirective(string $name, array $values): void {
$this->directives[$name] = $values;
}
public function toHeader(): string {
$parts = [];
foreach ($this->directives as $directive => $values) {
if (!empty($values)) {
$parts[] = $directive . ' ' . implode(' ', $values);
}
}
return implode('; ', $parts);
}
public function toMetaTag(): string {
return '<meta http-equiv="Content-Security-Policy" content="' . htmlspecialchars($this->toHeader()) . '">';
}
}
-419
View File
@@ -1,419 +0,0 @@
<?php
/**
* EnhancedSecurity - Advanced Security with WCAG Compliance
*
* Features:
* - Advanced XSS protection with DOMPurify integration
* - Content Security Policy headers
* - Input validation and sanitization
* - SQL injection prevention
* - File upload security
* - Rate limiting
* - CSRF protection
* - WCAG 2.1 AA compliant security
*/
class EnhancedSecurity {
private $config;
private $cspHeaders;
private $allowedTags;
private $allowedAttributes;
public function __construct($config = []) {
$this->config = $config;
$this->initializeSecurity();
}
/**
* Initialize security settings
*/
private function initializeSecurity() {
// WCAG compliant CSP headers
$this->cspHeaders = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Required for accessibility
"style-src 'self' 'unsafe-inline'", // Required for accessibility
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
];
// WCAG compliant allowed tags
$this->allowedTags = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'strong', 'em', 'u', 'i', 'b',
'a', 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'div', 'span', 'section', 'article', 'aside',
'header', 'footer', 'nav', 'main',
'img', 'picture', 'source',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'blockquote', 'code', 'pre',
'hr', 'small', 'sub', 'sup',
'button', 'input', 'label', 'select', 'option', 'textarea',
'form', 'fieldset', 'legend',
'time', 'address', 'abbr'
];
// WCAG compliant allowed attributes
$this->allowedAttributes = [
'href', 'src', 'alt', 'title', 'id', 'class',
'role', 'aria-label', 'aria-labelledby', 'aria-describedby',
'aria-expanded', 'aria-pressed', 'aria-current', 'aria-hidden',
'aria-live', 'aria-atomic', 'aria-busy', 'aria-relevant',
'aria-controls', 'aria-owns', 'aria-flowto', 'aria-errormessage',
'aria-invalid', 'aria-required', 'aria-disabled', 'aria-readonly',
'aria-haspopup', 'aria-orientation', 'aria-sort', 'aria-selected',
'aria-setsize', 'aria-posinset', 'aria-level', 'aria-valuemin',
'aria-valuemax', 'aria-valuenow', 'aria-valuetext',
'tabindex', 'accesskey', 'lang', 'dir', 'translate',
'for', 'name', 'type', 'value', 'placeholder', 'required',
'disabled', 'readonly', 'checked', 'selected', 'multiple',
'size', 'maxlength', 'minlength', 'min', 'max', 'step',
'pattern', 'autocomplete', 'autocorrect', 'autocapitalize',
'spellcheck', 'draggable', 'dropzone', 'data-*',
'width', 'height', 'style', 'loading', 'decoding',
'crossorigin', 'referrerpolicy', 'integrity', 'sizes', 'srcset',
'media', 'scope', 'colspan', 'rowspan', 'headers',
'datetime', 'pubdate', 'cite', 'rel', 'target',
'download', 'hreflang', 'type', 'method', 'action', 'enctype',
'novalidate', 'accept', 'accept-charset', 'autocomplete', 'target',
'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate',
'formtarget', 'list', 'multiple', 'pattern', 'placeholder',
'readonly', 'required', 'size', 'maxlength', 'minlength',
'min', 'max', 'step', 'autocomplete', 'autofocus', 'dirname',
'inputmode', 'wrap', 'rows', 'cols', 'role', 'aria-label',
'aria-labelledby', 'aria-describedby', 'aria-expanded', 'aria-pressed',
'aria-current', 'aria-hidden', 'aria-live', 'aria-atomic',
'aria-busy', 'aria-relevant', 'aria-controls', 'aria-owns',
'aria-flowto', 'aria-errormessage', 'aria-invalid', 'aria-required',
'aria-disabled', 'aria-readonly', 'aria-haspopup', 'aria-orientation',
'aria-sort', 'aria-selected', 'aria-setsize', 'aria-posinset',
'aria-level', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow',
'aria-valuetext', 'tabindex', 'accesskey', 'lang', 'dir', 'translate'
];
}
/**
* Set security headers
*/
public function setSecurityHeaders() {
// Content Security Policy
header('Content-Security-Policy: ' . implode('; ', $this->cspHeaders));
// Other security headers
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
// WCAG compliant headers
header('Feature-Policy: camera \'none\'; microphone \'none\'; geolocation \'none\'');
header('Access-Control-Allow-Origin: \'self\'');
}
/**
* Advanced XSS protection with accessibility preservation
*
* @param string $input Input to sanitize
* @param string $type Input type (html, text, url, etc.)
* @return string Sanitized input
*/
public function sanitizeInput($input, $type = 'text') {
if (empty($input)) {
return '';
}
switch ($type) {
case 'html':
return $this->sanitizeHTML($input);
case 'url':
return $this->sanitizeURL($input);
case 'email':
return $this->sanitizeEmail($input);
case 'filename':
return $this->sanitizeFilename($input);
case 'search':
return $this->sanitizeSearch($input);
default:
return $this->sanitizeText($input);
}
}
/**
* Sanitize HTML content while preserving accessibility
*
* @param string $html HTML content
* @return string Sanitized HTML
*/
private function sanitizeHTML($html) {
// Remove dangerous protocols
$html = preg_replace('/(javascript|vbscript|data|file):/i', '', $html);
// Remove script tags and content
$html = preg_replace('/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/mi', '', $html);
// Remove dangerous attributes
$html = preg_replace('/\s*(on\w+|style|expression)\s*=\s*["\'][^"\']*["\']/', '', $html);
// Remove HTML comments
$html = preg_replace('/<!--.*?-->/s', '', $html);
// Sanitize with allowed tags and attributes
$html = $this->filterHTML($html);
// Ensure accessibility attributes are preserved
$html = $this->ensureAccessibilityAttributes($html);
return trim($html);
}
/**
* Filter HTML with allowed tags and attributes
*
* @param string $html HTML content
* @return string Filtered HTML
*/
private function filterHTML($html) {
// Simple HTML filter (in production, use proper HTML parser)
$allowedTagsString = implode('|', $this->allowedTags);
// Remove disallowed tags
$html = preg_replace('/<\/?(?!' . $allowedTagsString . ')([a-z][a-z0-9]*)\b[^>]*>/i', '', $html);
// Remove dangerous attributes from allowed tags
foreach ($this->allowedTags as $tag) {
$html = preg_replace('/<' . $tag . '\b[^>]*?\s+(on\w+|style|expression)\s*=\s*["\'][^"\']*["\'][^>]*>/i', '<' . $tag . '>', $html);
}
return $html;
}
/**
* Ensure accessibility attributes are present
*
* @param string $html HTML content
* @return string HTML with accessibility attributes
*/
private function ensureAccessibilityAttributes($html) {
// Ensure images have alt text
$html = preg_replace('/<img(?![^>]*alt=)/i', '<img alt=""', $html);
// Ensure links have accessible labels
$html = preg_replace('/<a\s+href=["\'][^"\']*["\'](?![^>]*>.*?<\/a>)/i', '<a aria-label="Link"', $html);
// Ensure form inputs have labels
$html = preg_replace('/<input(?![^>]*id=)/i', '<input id="input-' . uniqid() . '"', $html);
return $html;
}
/**
* Sanitize text input
*
* @param string $text Text input
* @return string Sanitized text
*/
private function sanitizeText($text) {
// Remove null bytes
$text = str_replace("\0", '', $text);
// Normalize whitespace
$text = preg_replace('/\s+/', ' ', $text);
// Remove control characters except newlines and tabs
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
// HTML encode
return htmlspecialchars(trim($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Sanitize URL input
*
* @param string $url URL input
* @return string Sanitized URL
*/
private function sanitizeURL($url) {
// Remove dangerous protocols
$url = preg_replace('/^(javascript|vbscript|data|file):/i', '', $url);
// Validate URL format
if (!filter_var($url, FILTER_VALIDATE_URL) && !str_starts_with($url, '/') && !str_starts_with($url, '#')) {
return '';
}
return htmlspecialchars($url, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Sanitize email input
*
* @param string $email Email input
* @return string Sanitized email
*/
private function sanitizeEmail($email) {
$email = filter_var($email, FILTER_SANITIZE_EMAIL);
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '';
}
/**
* Sanitize filename input
*
* @param string $filename Filename input
* @return string Sanitized filename
*/
private function sanitizeFilename($filename) {
// Remove path traversal
$filename = str_replace(['../', '..\\', '..'], '', $filename);
// Remove dangerous characters
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename);
// Limit length
return substr($filename, 0, 255);
}
/**
* Sanitize search input
*
* @param string $search Search input
* @return string Sanitized search
*/
private function sanitizeSearch($search) {
// Allow search characters but remove dangerous ones
$search = preg_replace('/[<>"\']/', '', $search);
// Limit length
return substr(trim($search), 0, 100);
}
/**
* Validate CSRF token
*
* @param string $token CSRF token to validate
* @return bool True if valid
*/
public function validateCSRFToken($token) {
if (!isset($_SESSION['csrf_token'])) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Generate CSRF token
*
* @return string CSRF token
*/
public function generateCSRFToken() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
return $token;
}
/**
* Rate limiting check
*
* @param string $identifier Client identifier
* @param int $limit Request limit
* @param int $window Time window in seconds
* @return bool True if within limit
*/
public function checkRateLimit($identifier, $limit = 100, $window = 3600) {
$key = 'rate_limit_' . md5($identifier);
$current = time();
if (!isset($_SESSION[$key])) {
$_SESSION[$key] = [];
}
// Clean old entries
$_SESSION[$key] = array_filter($_SESSION[$key], function($timestamp) use ($current, $window) {
return $current - $timestamp < $window;
});
// Check limit
if (count($_SESSION[$key]) >= $limit) {
return false;
}
// Add current request
$_SESSION[$key][] = $current;
return true;
}
/**
* Validate file upload
*
* @param array $file File upload data
* @param array $allowedTypes Allowed MIME types
* @param int $maxSize Maximum file size in bytes
* @return array Validation result
*/
public function validateFileUpload($file, $allowedTypes = [], $maxSize = 5242880) {
$result = ['valid' => false, 'error' => ''];
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
$result['error'] = 'Invalid file upload';
return $result;
}
// Check file size
if ($file['size'] > $maxSize) {
$result['error'] = 'File too large';
return $result;
}
// Check file type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!empty($allowedTypes) && !in_array($mimeType, $allowedTypes)) {
$result['error'] = 'File type not allowed';
return $result;
}
// Check for dangerous file extensions
$dangerousExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'exe', 'bat', 'cmd', 'sh'];
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (in_array($extension, $dangerousExtensions)) {
$result['error'] = 'Dangerous file extension';
return $result;
}
$result['valid'] = true;
return $result;
}
/**
* Get security report
*
* @return array Security status report
*/
public function getSecurityReport() {
return [
'xss_protection' => 'advanced',
'csp_headers' => 'enabled',
'csrf_protection' => 'enabled',
'rate_limiting' => 'enabled',
'file_upload_security' => 'enabled',
'input_validation' => 'enhanced',
'accessibility_preserved' => true,
'security_score' => 100,
'wcag_compliant' => true
];
}
}
-127
View File
@@ -1,127 +0,0 @@
<?php
class SearchEngine {
private array $index = [];
private CacheInterface $cache;
public function __construct(?CacheInterface $cache = null) {
$this->cache = $cache ?? new FileCache();
$this->loadIndex();
}
public function indexContent(string $path, string $content, array $metadata = []): void {
$words = $this->tokenize($content);
$pathHash = md5($path);
foreach ($words as $word) {
if (!isset($this->index[$word])) {
$this->index[$word] = [];
}
if (!in_array($pathHash, $this->index[$word])) {
$this->index[$word][] = $pathHash;
}
}
// Store metadata for this path
$this->cache->set('search_meta_' . $pathHash, [
'path' => $path,
'title' => $metadata['title'] ?? basename($path),
'snippet' => $this->generateSnippet($content),
'last_modified' => $metadata['modified'] ?? time()
], 86400); // 24 hours
$this->saveIndex();
}
public function search(string $query, int $limit = 20): array {
$terms = $this->tokenize($query);
$results = [];
$pathScores = [];
foreach ($terms as $term) {
if (isset($this->index[$term])) {
foreach ($this->index[$term] as $pathHash) {
if (!isset($pathScores[$pathHash])) {
$pathScores[$pathHash] = 0;
}
$pathScores[$pathHash]++;
}
}
}
// Sort by relevance (term frequency)
arsort($pathScores);
// Get top results
$count = 0;
foreach ($pathScores as $pathHash => $score) {
if ($count >= $limit) break;
$metadata = $this->cache->get('search_meta_' . $pathHash);
if ($metadata) {
$results[] = array_merge($metadata, ['score' => $score]);
$count++;
}
}
return $results;
}
public function removeFromIndex(string $path): void {
$pathHash = md5($path);
foreach ($this->index as $word => $paths) {
$this->index[$word] = array_filter($paths, fn($hash) => $hash !== $pathHash);
if (empty($this->index[$word])) {
unset($this->index[$word]);
}
}
$this->cache->delete('search_meta_' . $pathHash);
$this->saveIndex();
}
public function clearIndex(): void {
$this->index = [];
$this->cache->clear();
$this->saveIndex();
}
private function tokenize(string $text): array {
// Convert to lowercase, remove punctuation, split into words
$text = strtolower($text);
$text = preg_replace('/[^\w\s]/u', ' ', $text);
$words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
// Filter out common stop words and short words
$stopWords = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can'];
$words = array_filter($words, function($word) use ($stopWords) {
return strlen($word) > 2 && !in_array($word, $stopWords);
});
return array_unique($words);
}
private function generateSnippet(string $content, int $length = 150): string {
// Remove HTML tags and extra whitespace
$content = strip_tags($content);
$content = preg_replace('/\s+/', ' ', $content);
if (strlen($content) <= $length) {
return $content;
}
return substr($content, 0, $length) . '...';
}
private function loadIndex(): void {
$cached = $this->cache->get('search_index');
if ($cached) {
$this->index = $cached;
}
}
private function saveIndex(): void {
$this->cache->set('search_index', $this->index, 86400); // 24 hours
}
}
-144
View File
@@ -1,144 +0,0 @@
<?php
/**
* SimpleTemplate - Lightweight template rendering engine
*
* Features:
* - Variable replacement with {{variable}} syntax
* - Unescaped HTML content with {{{variable}}} syntax
* - Conditional blocks with {{#variable}}...{{/variable}}
* - Negative conditionals with {{^variable}}...{{/variable}}
* - Partial includes with {{>partial}}
* - Simple string-based rendering (no external dependencies)
*/
class SimpleTemplate {
private $data;
/**
* Render template with data
*
* @param string $template Template content with placeholders
* @param array $data Data to populate template
* @return string Rendered template
*/
public static function render($template, $data) {
$instance = new self();
$instance->data = $data;
return $instance->renderTemplate($template);
}
/**
* Process template and replace placeholders
*
* @param string $template Template content
* @return string Processed template
*/
private function renderTemplate($template) {
// Handle partial includes first ({{>partial}})
$template = preg_replace_callback('/{{>([^}]+)}}/', [$this, 'replacePartial'], $template);
// Handle equal conditionals first
$template = preg_replace_callback('/{{#equal\s+(\w+)\s+["\']([^"\']+)["\']}}(.*?){{\/equal}}/s', function($matches) {
$key = $matches[1];
$expectedValue = $matches[2];
$content = $matches[3];
$actualValue = $this->data[$key] ?? '';
return ($actualValue === $expectedValue) ? $content : '';
}, $template);
// Handle conditional blocks
foreach ($this->data as $key => $value) {
if (is_array($value)) {
// Handle {{#key}}...{{/key}} blocks for arrays (iteration)
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match($pattern, $template, $matches)) {
$blockTemplate = $matches[1];
$replacement = '';
foreach ($value as $item) {
if (is_array($item)) {
// Create a temporary template instance for nested data
$tempTemplate = new self();
$tempTemplate->data = $item;
$replacement .= $tempTemplate->renderTemplate($blockTemplate);
} else {
// Simple array, replace {{.}} with the item value
$itemBlock = str_replace('{{.}}', htmlspecialchars($item, ENT_QUOTES, 'UTF-8'), $blockTemplate);
$replacement .= $itemBlock;
}
}
$template = preg_replace($pattern, $replacement, $template);
}
// Handle {{^key}}...{{/key}} blocks (negative condition for empty arrays)
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (empty($value)) {
// Show the content if array is empty
if (preg_match($pattern, $template, $matches)) {
$template = preg_replace($pattern, $matches[1], $template);
}
} else {
// Remove the content if array is not empty
$template = preg_replace($pattern, '', $template);
}
} elseif ((is_string($value) && !empty($value)) || (is_bool($value) && $value === true)) {
// Handle {{#key}}...{{/key}} blocks for truthy values
$pattern = '/{{#' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match($pattern, $template, $matches)) {
$replacement = $matches[1];
$template = preg_replace($pattern, $replacement, $template);
}
// Handle {{^key}}...{{/key}} blocks (negative condition)
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
$template = preg_replace($pattern, '', $template);
} else {
// Handle empty blocks
$pattern = '/{{#' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s';
$template = preg_replace($pattern, '', $template);
// Handle {{^key}}...{{/key}} blocks (show when empty)
$pattern = '/{{\^' . preg_quote($key, '/') . '}}(.*?){{\/' . preg_quote($key, '/') . '}}/s';
if (preg_match_all($pattern, $template, $matches)) {
foreach ($matches[1] as $match) {
$template = preg_replace('/{{\^' . preg_quote($key, '/') . '}}.*?{{\/' . preg_quote($key, '/') . '}}/s', $match, $template, 1);
}
}
}
}
// Handle variable replacements
foreach ($this->data as $key => $value) {
// Handle triple braces for unescaped HTML content
if (strpos($template, '{{{' . $key . '}}}') !== false) {
$template = str_replace('{{{' . $key . '}}}', is_string($value) ? $value : print_r($value, true), $template);
}
// Handle double braces for escaped content
elseif (strpos($template, '{{' . $key . '}}') !== false) {
if (is_string($value)) {
$template = str_replace('{{' . $key . '}}', htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), $template);
} elseif (is_array($value)) {
// For arrays, convert to JSON string for display
$template = str_replace('{{' . $key . '}}', htmlspecialchars(json_encode($value), ENT_QUOTES, 'UTF-8'), $template);
} else {
// Convert other types to string
$template = str_replace('{{' . $key . '}}', htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'), $template);
}
}
}
return $template;
}
/**
* Replace partial includes with data values
*
* @param array $matches Regex matches from preg_replace_callback
* @return string Replacement content
*/
private function replacePartial($matches) {
$partialName = $matches[1];
return isset($this->data[$partialName]) ? $this->data[$partialName] : $matches[0];
}
}
+3 -2
View File
@@ -24,10 +24,11 @@ if (!file_exists($configJsonPath)) {
],
'author' => [
'name' => 'E. Noorlander',
'website' => 'noorlander.info'
'website' => 'noorlander.info',
'git' => ''
],
'show_version' => true,
'enabled_plugins' => ['MQTTTracker', 'HTMLBlock'],
'enabled_plugins' => ['HTMLBlock', 'Navigation'],
'features' => [
'auto_link_pages' => true,
'search_enabled' => true,
+4 -3
View File
@@ -8,7 +8,7 @@
*
* Architecture:
* - config.php: Configuration loader that merges default settings with config.json
* - SimpleTemplate.php: Lightweight template engine for rendering HTML with placeholders
* - ThemeManager.php: Twig-based theme rendering + SCSS compilation
* - CodePressCMS.php: Main CMS class handling content, navigation, search, and rendering
*
* Usage:
@@ -32,14 +32,13 @@ if (file_exists($autoloader)) {
require_once $autoloader;
}
// Load template engine - renders HTML with {{variable}} placeholders and conditionals
// Load core utility classes
require_once 'class/Cache.php';
require_once 'class/RateLimiter.php';
require_once 'class/BotGuard.php';
require_once 'class/RequestLogger.php';
require_once 'class/GeoIP.php';
require_once 'class/Analytics.php';
require_once 'class/SimpleTemplate.php';
require_once 'class/ThemeManager.php';
// Load Logger class - structured logging with log levels
@@ -47,7 +46,9 @@ require_once 'class/Logger.php';
require_once 'class/LogManager.php';
// Load Plugin system
require_once 'plugin/PluginAPIInterface.php';
require_once 'plugin/CMSAPI.php';
require_once 'plugin/AdminPluginAPI.php';
require_once 'plugin/PluginManager.php';
// Load ContentAPI class - provides CMS data access for PHP content files
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* Lightweight API wrapper for plugins in the admin context.
* Provides read-only access to the site config (no CMS instance needed).
*/
class AdminPluginAPI implements PluginAPIInterface
{
private array $config;
private string $projectRoot;
public function __construct(array $siteConfig, string $projectRoot = '')
{
$this->config = $siteConfig;
$this->projectRoot = $projectRoot !== '' ? $projectRoot : dirname(__DIR__, 3);
}
/**
* Get configuration value using dot notation.
*/
public function getConfig(string $key, $default = null)
{
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get the project root directory.
*/
public function getProjectRoot(): string
{
return $this->projectRoot;
}
/**
* Get the content directory path.
*/
public function getContentDir(): string
{
return $this->config['content_dir'] ?? ($this->projectRoot . '/content');
}
/**
* Get the plugins directory path.
*/
public function getPluginsDir(): string
{
return $this->projectRoot . '/plugins';
}
/**
* Get the list of enabled plugins.
*/
public function getEnabledPlugins(): array
{
return $this->config['enabled_plugins'] ?? [];
}
/**
* Get the version info from version.php.
*/
public function getVersionInfo(): array
{
$versionFile = $this->projectRoot . '/version.php';
if (file_exists($versionFile)) {
$data = include $versionFile;
if (is_array($data)) {
return $data;
}
}
return ['version' => '0.0.0'];
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
class CMSAPI
class CMSAPI implements PluginAPIInterface
{
private CodePressCMS $cms;
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* Interface for plugin API objects.
* Both CMSAPI (front-end) and AdminPluginAPI (admin) implement this.
*/
interface PluginAPIInterface
{
public function getConfig(string $key, $default = null);
}
+59 -111
View File
@@ -4,7 +4,7 @@ class PluginManager
{
private array $plugins = [];
private string $pluginsPath;
private ?CMSAPI $api = null;
private $api = null;
private array $enabledPlugins = [];
private array $actions = [];
private array $filters = [];
@@ -16,7 +16,7 @@ class PluginManager
$this->loadPlugins();
}
public function setAPI(CMSAPI $api): void
public function setAPI($api): void
{
$this->api = $api;
@@ -50,10 +50,6 @@ class PluginManager
$className = $pluginName;
if (class_exists($className)) {
$this->plugins[$pluginName] = new $className();
if ($this->api && method_exists($this->plugins[$pluginName], 'setAPI')) {
$this->plugins[$pluginName]->setAPI($this->api);
}
// Auto-register hooks from plugin methods
$hookMethods = ['onPageLoad', 'onBeforeRender', 'onAfterRender', 'onSearch', 'onMenuBuild'];
@@ -132,13 +128,7 @@ class PluginManager
{
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
if (isset($config['viewable']) && $config['viewable'] === false) {
return false;
}
// System plugins don't show in sidebar
if (isset($config['type']) && $config['type'] === 'system') {
return false;
}
return !isset($config['viewable']) || $config['viewable'] !== false;
}
return true;
}
@@ -146,27 +136,28 @@ class PluginManager
public function getSidebarContent(?array $allowedPlugins = null): string
{
$sidebarContent = '';
// If allowedPlugins is specified, iterate in that order (user-defined order)
if ($allowedPlugins !== null) {
foreach ($allowedPlugins as $pluginName) {
$plugin = $this->plugins[$pluginName] ?? null;
if (!$plugin || !$this->isPluginViewable($plugin) || !method_exists($plugin, 'getSidebarContent')) {
continue;
}
$content = $plugin->getSidebarContent();
if (trim($content) === '') {
continue;
}
$title = 'Plugin';
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
$title = $config['title'] ?? 'Plugin';
}
$sidebarContent .= '
foreach ($this->plugins as $pluginName => $plugin) {
if (!$this->isPluginViewable($plugin) || !method_exists($plugin, 'getSidebarContent')) {
continue;
}
if ($allowedPlugins !== null && !in_array($pluginName, $allowedPlugins, true)) {
continue;
}
$content = $plugin->getSidebarContent();
if (trim($content) === '') {
continue;
}
$title = 'Plugin';
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
$title = $config['title'] ?? 'Plugin';
}
$sidebarContent .= '
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">' . htmlspecialchars($title) . '</h5>
@@ -175,37 +166,8 @@ class PluginManager
' . $content . '
</div>
</div>';
}
} else {
// No filter — show all viewable plugins in load order
foreach ($this->plugins as $pluginName => $plugin) {
if (!$this->isPluginViewable($plugin) || !method_exists($plugin, 'getSidebarContent')) {
continue;
}
$content = $plugin->getSidebarContent();
if (trim($content) === '') {
continue;
}
$title = 'Plugin';
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
$title = $config['title'] ?? 'Plugin';
}
$sidebarContent .= '
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">' . htmlspecialchars($title) . '</h5>
</div>
<div class="card-body">
' . $content . '
</div>
</div>';
}
}
return $sidebarContent;
}
@@ -229,28 +191,19 @@ class PluginManager
}
/**
* Get admin menu items from system plugins.
* Each system plugin can register admin menu items via getAdminMenu().
* Returns an array of [label, route, icon] tuples.
* Collect admin menu items from all enabled plugins that implement getAdminMenu().
* Each plugin returns [['route' => 'plugin-name/action', 'label' => 'Label', 'icon' => 'bi-icon']].
*
* @return array Admin menu items
* @return array Admin menu items from all plugins
*/
public function getAdminMenuItems(): array
{
$items = [];
foreach ($this->plugins as $pluginName => $plugin) {
// Only system plugins provide admin menu items
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
if (($config['type'] ?? 'content') !== 'system') {
continue;
}
}
if (method_exists($plugin, 'getAdminMenu')) {
$menuItems = $plugin->getAdminMenu();
if (is_array($menuItems)) {
foreach ($menuItems as $item) {
$pluginItems = $plugin->getAdminMenu();
if (is_array($pluginItems)) {
foreach ($pluginItems as $item) {
$items[] = $item;
}
}
@@ -260,49 +213,44 @@ class PluginManager
}
/**
* Handle an admin route for a system plugin.
* Called when a route matches a plugin's registered admin route.
* Returns the rendered content string, or null if no plugin handled it.
* Dispatch an admin route to a plugin that handles it.
* Plugins implement handleAdminRoute(string $action): ?string (returns rendered HTML or null).
*
* @param string $route The admin route
* @return string|null Rendered content or null if not handled
* @param string $pluginName Plugin name
* @param string $action Action/sub-route within the plugin
* @return string|null Rendered HTML, or null if plugin doesn't handle it
*/
public function handleAdminRoute(string $route): ?string
public function dispatchAdminRoute(string $pluginName, string $action): ?string
{
foreach ($this->plugins as $pluginName => $plugin) {
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
if (($config['type'] ?? 'content') !== 'system') {
continue;
}
}
if (method_exists($plugin, 'getAdminRoutes')) {
$routes = $plugin->getAdminRoutes();
if (is_array($routes) && in_array($route, $routes, true)) {
ob_start();
$plugin->handleAdminRoute($route);
return ob_get_clean();
}
}
$plugin = $this->getPlugin($pluginName);
if ($plugin === null) {
return null;
}
if (method_exists($plugin, 'handleAdminRoute')) {
return $plugin->handleAdminRoute($action);
}
return null;
}
/**
* Get a plugin's type (content or system).
* Find which plugin handles a given admin route.
*
* @param string $pluginName Plugin name
* @return string 'content', 'system', or 'content' if unknown
* @param string $route The admin route (e.g. 'statistics' or 'statistics/details')
* @return array|null ['plugin' => name, 'action' => action] or null
*/
public function getPluginType(string $pluginName): string
public function resolveAdminRoute(string $route): ?array
{
$pluginDir = $this->pluginsPath . '/' . $pluginName;
$jsonFile = $pluginDir . '/plugin.json';
if (file_exists($jsonFile)) {
$data = json_decode(file_get_contents($jsonFile), true);
return $data['type'] ?? 'content';
foreach ($this->getAdminMenuItems() as $item) {
$itemRoute = $item['route'] ?? '';
// Check if the requested route starts with this plugin's route
if ($route === $itemRoute || str_starts_with($route, $itemRoute . '/')) {
$action = substr($route, strlen($itemRoute) + 1);
return [
'plugin' => $item['plugin'] ?? '',
'action' => $action,
];
}
}
return 'content';
return null;
}
}
-40
View File
@@ -1,40 +0,0 @@
<?php
return [
'site_title' => 'CodePress',
'home' => 'Home',
'search' => 'Search',
'search_placeholder' => 'Search...',
'search_button' => 'Search',
'welcome' => 'Welcome',
'created' => 'Created',
'modified' => 'Modified',
'author' => 'Author',
'manual' => 'Manual',
'no_content' => 'No content found',
'no_results' => 'No results found',
'results_found' => 'results found',
'breadcrumb_home' => 'Home',
'file_details' => 'File details',
'guide' => 'Guide',
'powered_by' => 'Powered by',
't_powered_by' => 'Powered by',
'directory_empty' => 'This directory is empty',
'page_not_found' => 'Page Not Found',
'page_not_found_text' => 'The page you are looking for does not exist.',
'mappen' => 'Folders',
'paginas' => 'Pages',
'author_website' => 'Author website',
'author_git' => 'Author Git',
'plugins' => 'Plugins',
'templates' => 'Templates',
'layouts' => 'Layouts',
'sidebar_content' => 'Sidebar + Content',
'content_only' => 'Content Only',
'sidebar_only' => 'Sidebar Only',
'content_sidebar' => 'Content + Sidebar',
'plugin_development' => 'Plugin Development',
'template_system' => 'Template System',
'mqtt_tracking' => 'MQTT Tracking',
'real_time_analytics' => 'Real-time Analytics',
'go_to' => 'Go to'
];
-40
View File
@@ -1,40 +0,0 @@
<?php
return [
'site_title' => 'CodePress',
'home' => 'Home',
'search' => 'Zoeken',
'search_placeholder' => 'Zoeken...',
'search_button' => 'Zoeken',
'welcome' => 'Welkom',
'created' => 'Aangemaakt',
'modified' => 'Aangepast',
'author' => 'Auteur',
'manual' => 'Handleiding',
'no_content' => 'Geen inhoud gevonden',
'no_results' => 'Geen resultaten gevonden',
'results_found' => 'resultaten gevonden',
'breadcrumb_home' => 'Home',
'file_details' => 'Bestandsdetails',
'guide' => 'Handleiding',
'powered_by' => 'Mogelijk gemaakt door',
't_powered_by' => 'Mogelijk gemaakt door',
'directory_empty' => 'Deze map is leeg',
'page_not_found' => 'Pagina niet gevonden',
'page_not_found_text' => 'De pagina die u zoekt bestaat niet.',
'mappen' => 'Mappen',
'paginas' => 'Pagina\'s',
'author_website' => 'Auteur website',
'author_git' => 'Auteur Git',
'plugins' => 'Plugins',
'templates' => 'Templates',
'layouts' => 'Layouts',
'sidebar_content' => 'Sidebar + Content',
'content_only' => 'Alleen Content',
'sidebar_only' => 'Alleen Sidebar',
'content_sidebar' => 'Content + Sidebar',
'plugin_development' => 'Plugin Ontwikkeling',
'template_system' => 'Template Systeem',
'mqtt_tracking' => 'MQTT Tracking',
'real_time_analytics' => 'Real-time Analytics',
'go_to' => 'Ga naar'
];