CMS 2.0 - Theme engine, logging, admin improvements
Major changes: - New ThemeManager with Twig templating and SCSS compilation - Dynamic themes system (themes/default, themes/demo) - LogManager with SQLite storage and syslog forwarding - RequestLogger with static helper methods - Admin UI overhaul (Bootstrap 5, dark mode) - Admin config page with logging and theme settings - Admin logs page with filters and search - Removed legacy Mustache templates - Removed test plugin and theme - Composer dependencies: Twig, scssphp, CommonMark, MaxMind GeoIP
This commit is contained in:
@@ -591,7 +591,7 @@ class CodePressCMS {
|
||||
}
|
||||
}
|
||||
|
||||
$authorWebsite = $this->config['author']['website'] ?? '';
|
||||
$authorWebsite = $this->normalizeUrl($this->config['author']['website'] ?? '');
|
||||
if ($authorWebsite !== '') {
|
||||
$authorHost = parse_url($authorWebsite, PHP_URL_HOST);
|
||||
if ($authorHost) {
|
||||
@@ -602,6 +602,25 @@ class CodePressCMS {
|
||||
return array_values(array_unique(array_filter($hosts)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a URL: if no scheme is present, prepend https://.
|
||||
* Handles hostnames stored without protocol (e.g. "noorlander.info").
|
||||
*
|
||||
* @param string $url Raw URL or hostname
|
||||
* @return string Normalized absolute URL, or '' if empty
|
||||
*/
|
||||
private function normalizeUrl(string $url): string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) {
|
||||
return 'https://' . $url;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Markdown content to HTML using League CommonMark
|
||||
*
|
||||
@@ -1140,7 +1159,7 @@ class CodePressCMS {
|
||||
'is_guide_page' => isset($_GET['guide']),
|
||||
'lang_switch_url' => '',
|
||||
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
|
||||
'author_website' => $this->config['author']['website'] ?? '#',
|
||||
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
|
||||
'author_git' => 'https://git.noorlander.info/E.Noorlander',
|
||||
'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',
|
||||
@@ -1156,8 +1175,6 @@ class CodePressCMS {
|
||||
'nav_height' => $this->config['theme']['nav_height'] ?? '42',
|
||||
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
|
||||
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
|
||||
'background_image_css' => $this->getBackgroundImageCss(),
|
||||
'background_image_opacity' => $this->getBackgroundImageOpacity(),
|
||||
// Language
|
||||
'current_lang' => $this->currentLanguage,
|
||||
'current_lang_upper' => strtoupper($this->currentLanguage),
|
||||
@@ -1216,36 +1233,21 @@ class CodePressCMS {
|
||||
// Don't show site title link on guide page
|
||||
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
|
||||
|
||||
// Load and render all templates with data
|
||||
$layoutTemplate = file_get_contents($this->config['templates_dir'] . '/layout.mustache');
|
||||
$headerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/header.mustache');
|
||||
$navigationTemplate = file_get_contents($this->config['templates_dir'] . '/assets/navigation.mustache');
|
||||
$footerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache');
|
||||
|
||||
// Determine content type and load appropriate template
|
||||
$contentType = $this->getContentType($page);
|
||||
$contentTemplateFile = $this->config['templates_dir'] . '/' . $contentType . '_content.mustache';
|
||||
$contentTemplate = file_exists($contentTemplateFile) ? file_get_contents($contentTemplateFile) : '<div class="content">{{{content}}}</div>';
|
||||
|
||||
// Map legacy frontmatter layout values to theme template keys
|
||||
$layoutKey = $this->mapLayoutToThemeKey($layout);
|
||||
|
||||
// Add theme asset URLs to template data
|
||||
$themeManager = new ThemeManager($this->config);
|
||||
$templateData['theme_title'] = $themeManager->getTitle();
|
||||
$templateData['theme_css_url'] = $themeManager->getCssUrl();
|
||||
$templateData['theme_js_url'] = $themeManager->getJsUrl();
|
||||
$templateData['theme_config'] = $themeManager->getConfig();
|
||||
|
||||
// Render the page through the active theme
|
||||
$renderedLayout = $themeManager->render($layoutKey, $templateData);
|
||||
|
||||
|
||||
// Render all templates with data
|
||||
$renderedHeader = SimpleTemplate::render($headerTemplate, $templateData);
|
||||
$renderedNavigation = SimpleTemplate::render($navigationTemplate, $templateData);
|
||||
$renderedFooter = SimpleTemplate::render($footerTemplate, $templateData);
|
||||
$renderedContent = SimpleTemplate::render($contentTemplate, $templateData);
|
||||
|
||||
// Replace partials in layout
|
||||
$finalTemplate = str_replace('{{>header}}', $renderedHeader, $layoutTemplate);
|
||||
$finalTemplate = str_replace('{{>navigation}}', $renderedNavigation, $finalTemplate);
|
||||
$finalTemplate = str_replace('{{>footer}}', $renderedFooter, $finalTemplate);
|
||||
$finalTemplate = str_replace('{{>content_template}}', $renderedContent, $finalTemplate);
|
||||
|
||||
// Render the final layout with all template data
|
||||
$renderedLayout = SimpleTemplate::render($finalTemplate, $templateData);
|
||||
|
||||
echo $renderedLayout;
|
||||
|
||||
|
||||
$this->pluginManager->doAction('onAfterRender', $renderedLayout);
|
||||
}
|
||||
|
||||
@@ -1371,6 +1373,25 @@ class CodePressCMS {
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a frontmatter layout value to a theme template key.
|
||||
*
|
||||
* Legacy values are translated to the new theme keys. Unknown values
|
||||
* are passed through so ThemeManager can fall back to default_layout.
|
||||
*
|
||||
* @param string $layout Layout value from page metadata
|
||||
* @return string Theme template key
|
||||
*/
|
||||
private function mapLayoutToThemeKey(string $layout): string {
|
||||
return match ($layout) {
|
||||
'content' => 'full_content',
|
||||
'sidebar-content', 'content-sidebar' => 'left_sidebar',
|
||||
'content-sidebar-reverse' => 'right_sidebar',
|
||||
'sidebar' => 'custom1',
|
||||
default => $layout,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine content type for current page
|
||||
*
|
||||
@@ -1540,24 +1561,6 @@ class CodePressCMS {
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getBackgroundImageCss(): string
|
||||
{
|
||||
$bg = $this->config['theme']['background_image'] ?? '';
|
||||
if (empty($bg)) {
|
||||
return 'none';
|
||||
}
|
||||
if (str_starts_with($bg, 'http')) {
|
||||
return 'url(' . $bg . ')';
|
||||
}
|
||||
return 'url(/themes/' . $bg . ')';
|
||||
}
|
||||
|
||||
private function getBackgroundImageOpacity(): int
|
||||
{
|
||||
$opacity = intval($this->config['theme']['background_image_opacity'] ?? 100);
|
||||
return max(0, min(100, $opacity));
|
||||
}
|
||||
|
||||
private function processContent(string $content): string
|
||||
{
|
||||
return str_replace('-/assets/', '/-assets/', $content);
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* LogManager - Dynamic logging for CodePress CMS
|
||||
*
|
||||
* Supports three drivers:
|
||||
* - syslog: Send log entries to a remote syslog server (UDP) or local syslog.
|
||||
* - sqlite: Store log entries in a SQLite database (default when no syslog
|
||||
* server is configured and SQLite is available).
|
||||
* - file: Fallback to plain text files when SQLite is unavailable.
|
||||
*
|
||||
* Which event types are recorded is controlled dynamically via the
|
||||
* "logging.events" config section (admin, requests, errors, security,
|
||||
* content, system).
|
||||
*/
|
||||
class LogManager {
|
||||
const EVENT_ADMIN = 'admin';
|
||||
const EVENT_REQUESTS = 'requests';
|
||||
const EVENT_ERRORS = 'errors';
|
||||
const EVENT_SECURITY = 'security';
|
||||
const EVENT_CONTENT = 'content';
|
||||
const EVENT_SYSTEM = 'system';
|
||||
|
||||
private static $config = null;
|
||||
private static $pdo = null;
|
||||
private static $dbPath = null;
|
||||
|
||||
/**
|
||||
* Initialize the log manager with the logging config section.
|
||||
*
|
||||
* @param array $loggingConfig The "logging" section from config.json
|
||||
*/
|
||||
public static function init(array $loggingConfig): void
|
||||
{
|
||||
self::$config = $loggingConfig;
|
||||
self::$dbPath = dirname(__DIR__, 3) . '/admin/storage/logs/codepress.sqlite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether logging is enabled at all.
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return !empty(self::$config['enabled']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a given event type should be recorded.
|
||||
*
|
||||
* @param string $event One of the EVENT_* constants
|
||||
*/
|
||||
public static function isEventEnabled(string $event): bool
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
$events = self::$config['events'] ?? [];
|
||||
return !empty($events[$event]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local storage driver: 'sqlite' (falling back to 'file' if
|
||||
* SQLite is unavailable). Syslog is an additional output, not a storage
|
||||
* driver, so it never replaces local storage.
|
||||
*/
|
||||
public static function getDriver(): string
|
||||
{
|
||||
$driver = self::$config['driver'] ?? 'sqlite';
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return self::sqliteAvailable() ? 'sqlite' : 'file';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a log entry if the event type is enabled.
|
||||
*
|
||||
* @param string $event Event type (EVENT_* constant)
|
||||
* @param string $level Log level (info, warning, error, debug)
|
||||
* @param string $message Log message
|
||||
* @param array $context Additional structured context
|
||||
*/
|
||||
public static function log(string $event, string $level, string $message, array $context = []): void
|
||||
{
|
||||
if (!self::isEventEnabled($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'time' => date('Y-m-d H:i:s'),
|
||||
'event' => $event,
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'ip' => $context['ip'] ?? (class_exists('RequestLogger') ? RequestLogger::getClientIp() : ''),
|
||||
'context' => $context,
|
||||
];
|
||||
|
||||
$driver = self::getDriver();
|
||||
|
||||
// Always store locally (sqlite or file fallback) so the dynamic log
|
||||
// in the admin always has entries.
|
||||
if ($driver === 'sqlite') {
|
||||
self::writeSqlite($entry);
|
||||
} else {
|
||||
self::writeFile($entry);
|
||||
}
|
||||
|
||||
// Additionally forward to a remote syslog server if one is configured.
|
||||
$syslogHost = trim(self::$config['syslog_host'] ?? '');
|
||||
if ($syslogHost !== '') {
|
||||
self::writeSyslog($entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a log entry to a remote syslog server over UDP.
|
||||
*/
|
||||
private static function writeSyslog(array $entry): void
|
||||
{
|
||||
$host = trim(self::$config['syslog_host'] ?? '');
|
||||
$port = (int)(self::$config['syslog_port'] ?? 514);
|
||||
$facility = self::syslogFacility(self::$config['syslog_facility'] ?? 'local0');
|
||||
$ident = self::$config['syslog_ident'] ?? 'codepress';
|
||||
|
||||
$severity = self::syslogSeverity($entry['level']);
|
||||
$pri = ($facility * 8) + $severity;
|
||||
|
||||
$msg = '<' . $pri . '>' . date('M d H:i:s') . ' ' . $ident . '[' . getmypid() . ']: '
|
||||
. '[' . $entry['event'] . '] [' . $entry['level'] . '] ' . $entry['message'];
|
||||
|
||||
$sock = @fsockopen('udp://' . $host, $port, $errno, $errstr, 2);
|
||||
if ($sock) {
|
||||
@fwrite($sock, $msg . "\n");
|
||||
@fclose($sock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a log entry in the SQLite database.
|
||||
*/
|
||||
private static function writeSqlite(array $entry): void
|
||||
{
|
||||
$pdo = self::getPdo();
|
||||
if ($pdo === null) {
|
||||
// SQLite failed -> fall back to file
|
||||
self::writeFile($entry);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO logs (time, event, level, message, ip, context) VALUES (:time, :event, :level, :message, :ip, :context)'
|
||||
);
|
||||
$stmt->execute([
|
||||
':time' => $entry['time'],
|
||||
':event' => $entry['event'],
|
||||
':level' => $entry['level'],
|
||||
':message' => $entry['message'],
|
||||
':ip' => $entry['ip'],
|
||||
':context' => json_encode($entry['context']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
self::writeFile($entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a log entry to a plain text file (fallback driver).
|
||||
*/
|
||||
private static function writeFile(array $entry): void
|
||||
{
|
||||
$dir = dirname(self::$dbPath);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
$file = $dir . '/codepress.log';
|
||||
$line = '[' . $entry['time'] . '] [' . $entry['event'] . '] [' . $entry['level'] . '] ['
|
||||
. $entry['ip'] . '] ' . $entry['message'] . "\n";
|
||||
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (and lazily create) the PDO connection to the SQLite database.
|
||||
*/
|
||||
private static function getPdo(): ?\PDO
|
||||
{
|
||||
if (self::$pdo !== null) {
|
||||
return self::$pdo;
|
||||
}
|
||||
if (!self::sqliteAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dir = dirname(self::$dbPath);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
self::$pdo = new \PDO('sqlite:' . self::$dbPath);
|
||||
self::$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
|
||||
self::$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
context TEXT
|
||||
)'
|
||||
);
|
||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_event ON logs (event)');
|
||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_time ON logs (time)');
|
||||
return self::$pdo;
|
||||
} catch (\Throwable $e) {
|
||||
self::$pdo = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SQLite PDO driver is available.
|
||||
*/
|
||||
private static function sqliteAvailable(): bool
|
||||
{
|
||||
return class_exists('PDO') && in_array('sqlite', \PDO::getAvailableDrivers(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a facility name to its syslog numeric value.
|
||||
*/
|
||||
private static function syslogFacility(string $facility): int
|
||||
{
|
||||
$map = [
|
||||
'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3,
|
||||
'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7,
|
||||
'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11,
|
||||
'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19,
|
||||
'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23,
|
||||
];
|
||||
return $map[$facility] ?? 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a log level to its syslog severity value.
|
||||
*/
|
||||
private static function syslogSeverity(string $level): int
|
||||
{
|
||||
$map = [
|
||||
'debug' => 7,
|
||||
'info' => 6,
|
||||
'notice' => 5,
|
||||
'warning' => 4,
|
||||
'error' => 3,
|
||||
'critical' => 2,
|
||||
'alert' => 1,
|
||||
'emergency' => 0,
|
||||
];
|
||||
return $map[strtolower($level)] ?? 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query recent log entries from the active store.
|
||||
*
|
||||
* @param int $limit Number of entries to return
|
||||
* @param string|null $event Optional event filter
|
||||
* @param string|null $level Optional level filter (info, warning, error, ...)
|
||||
* @param string|null $search Optional text search on the message
|
||||
* @return array List of log entries (newest first)
|
||||
*/
|
||||
public static function getLogs(int $limit = 200, ?string $event = null, ?string $level = null, ?string $search = null): array
|
||||
{
|
||||
$driver = self::getDriver();
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
$pdo = self::getPdo();
|
||||
if ($pdo !== null) {
|
||||
try {
|
||||
$sql = 'SELECT time, event, level, message, ip FROM logs';
|
||||
$conds = [];
|
||||
$params = [];
|
||||
if ($event !== null && $event !== '') {
|
||||
$conds[] = 'event = :event';
|
||||
$params[':event'] = $event;
|
||||
}
|
||||
if ($level !== null && $level !== '') {
|
||||
$conds[] = 'level = :level';
|
||||
$params[':level'] = $level;
|
||||
}
|
||||
if ($search !== null && $search !== '') {
|
||||
$conds[] = 'message LIKE :search';
|
||||
$params[':search'] = '%' . $search . '%';
|
||||
}
|
||||
if (!empty($conds)) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $conds);
|
||||
}
|
||||
$sql .= ' ORDER BY id DESC LIMIT ' . (int)$limit;
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File fallback
|
||||
$dir = dirname(self::$dbPath);
|
||||
$file = $dir . '/codepress.log';
|
||||
if (!file_exists($file)) {
|
||||
return [];
|
||||
}
|
||||
$lines = file($file);
|
||||
$lines = array_slice($lines, -$limit);
|
||||
$logs = [];
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
|
||||
if ($event !== null && $event !== '' && $m[2] !== $event) {
|
||||
continue;
|
||||
}
|
||||
if ($level !== null && $level !== '' && $m[3] !== $level) {
|
||||
continue;
|
||||
}
|
||||
if ($search !== null && $search !== '' && stripos($m[5], $search) === false) {
|
||||
continue;
|
||||
}
|
||||
$logs[] = [
|
||||
'time' => $m[1],
|
||||
'event' => $m[2],
|
||||
'level' => $m[3],
|
||||
'ip' => $m[4],
|
||||
'message' => $m[5],
|
||||
];
|
||||
}
|
||||
}
|
||||
return array_reverse($logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored log entries.
|
||||
*/
|
||||
public static function clear(): void
|
||||
{
|
||||
$driver = self::getDriver();
|
||||
if ($driver === 'sqlite') {
|
||||
$pdo = self::getPdo();
|
||||
if ($pdo !== null) {
|
||||
try {
|
||||
$pdo->exec('DELETE FROM logs');
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
// fall through to file
|
||||
}
|
||||
}
|
||||
}
|
||||
$dir = dirname(self::$dbPath);
|
||||
$file = $dir . '/codepress.log';
|
||||
if (file_exists($file)) {
|
||||
@file_put_contents($file, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,14 @@ class Logger {
|
||||
|
||||
// Write to file with error suppression (graceful degradation)
|
||||
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
|
||||
// Route through the dynamic log manager (errors/system events)
|
||||
if (class_exists('LogManager')) {
|
||||
$event = ($level === self::ERROR || $level === self::WARNING)
|
||||
? LogManager::EVENT_ERRORS
|
||||
: LogManager::EVENT_SYSTEM;
|
||||
LogManager::log($event, strtolower($level), $message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,79 @@ class RequestLogger
|
||||
return $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an IP matches any entry in a list of IPs/CIDR ranges.
|
||||
*
|
||||
* Supports exact IPv4/IPv6 addresses and CIDR notation (e.g. 192.168.0.0/16).
|
||||
*
|
||||
* @param string $ip The client IP to test
|
||||
* @param array $list List of IPs and/or CIDR ranges
|
||||
* @return bool True if the IP matches any entry
|
||||
*/
|
||||
public static function ipMatchesList(string $ip, array $list): bool
|
||||
{
|
||||
$ip = trim($ip);
|
||||
if ($ip === '') {
|
||||
return false;
|
||||
}
|
||||
$isV6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$packed = $isV6 ? inet_pton($ip) : inet_pton($ip);
|
||||
|
||||
foreach ($list as $entry) {
|
||||
$entry = trim((string)$entry);
|
||||
if ($entry === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if ($entry === $ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CIDR notation
|
||||
if (strpos($entry, '/') !== false) {
|
||||
[$subnet, $bits] = array_pad(explode('/', $entry, 2), 2, null);
|
||||
$subnet = trim($subnet);
|
||||
$subnetPacked = inet_pton($subnet);
|
||||
if ($subnetPacked === false || $packed === false) {
|
||||
continue;
|
||||
}
|
||||
// Ensure both are the same address family
|
||||
if (strlen($subnetPacked) !== strlen($packed)) {
|
||||
continue;
|
||||
}
|
||||
$maxBits = strlen($packed) * 8;
|
||||
$bits = (int)$bits;
|
||||
if ($bits < 0 || $bits > $maxBits) {
|
||||
continue;
|
||||
}
|
||||
if ($bits === 0) {
|
||||
return true;
|
||||
}
|
||||
$fullBytes = intdiv($bits, 8);
|
||||
$remainingBits = $bits % 8;
|
||||
$match = true;
|
||||
for ($i = 0; $i < $fullBytes; $i++) {
|
||||
if ($subnetPacked[$i] !== $packed[$i]) {
|
||||
$match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($match && $remainingBits > 0) {
|
||||
$mask = 0xFF << (8 - $remainingBits);
|
||||
if ((ord($subnetPacked[$fullBytes]) & $mask) !== (ord($packed[$fullBytes]) & $mask)) {
|
||||
$match = false;
|
||||
}
|
||||
}
|
||||
if ($match) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getClientIp(): string
|
||||
{
|
||||
$headerKeys = [
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use ScssPhp\ScssPhp\Compiler;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
/**
|
||||
* ThemeManager - Resolves and renders the active theme
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Resolve the active theme directory from config
|
||||
* - Load theme.json (title, default_layout, template mapping, colors)
|
||||
* - Build a Twig environment rooted at the theme directory
|
||||
* - Compile theme SCSS to CSS at runtime (cached by mtime)
|
||||
* - Map a requested layout to a concrete .twig template, falling back
|
||||
* to the theme's default_layout when the layout is unknown
|
||||
*/
|
||||
class ThemeManager {
|
||||
private $config;
|
||||
private $themeDir;
|
||||
private $themeConfig;
|
||||
private $twig;
|
||||
private $compiledCssDir;
|
||||
|
||||
/**
|
||||
* @param array $config Full CMS config (must contain 'theme_dir' and 'theme')
|
||||
*/
|
||||
public function __construct(array $config) {
|
||||
$this->config = $config;
|
||||
$this->themeDir = $config['theme_dir'] ?? (__DIR__ . '/../../../themes/' . ($config['active_theme'] ?? 'default'));
|
||||
$this->themeConfig = $config['theme'] ?? [];
|
||||
$this->compiledCssDir = __DIR__ . '/../../../public/themes';
|
||||
|
||||
$loader = new FilesystemLoader($this->themeDir);
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => false,
|
||||
'autoescape' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute path of the active theme directory
|
||||
*/
|
||||
public function getThemeDir(): string {
|
||||
return $this->themeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw theme.json config array
|
||||
*/
|
||||
public function getThemeConfig(): array {
|
||||
return $this->themeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme title (from theme.json 'title' or 'name')
|
||||
*/
|
||||
public function getTitle(): string {
|
||||
return $this->themeConfig['title'] ?? $this->themeConfig['name'] ?? basename($this->themeDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme config section (default_template, background settings, etc.)
|
||||
*/
|
||||
public function getConfig(): array {
|
||||
return $this->themeConfig['config'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme template section (layout key => .twig file mapping)
|
||||
*/
|
||||
public function getTemplates(): array {
|
||||
return $this->themeConfig['template'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the .twig template file for a requested layout.
|
||||
*
|
||||
* Templates are defined in theme.json under the "template" section:
|
||||
* { "template": { "full_content": "full_content.twig", ... } }
|
||||
* The default template is defined in the "config" section:
|
||||
* { "config": { "default_template": "full_content", ... } }
|
||||
*
|
||||
* Priority:
|
||||
* 1. If the layout is a known key in the "template" section, use its mapped file.
|
||||
* 2. Otherwise fall back to config.default_template.
|
||||
* 3. Final safety net: full_content.twig.
|
||||
*
|
||||
* @param string $layout Requested layout key (e.g. 'left_sidebar')
|
||||
* @return string Template name usable by the Twig loader
|
||||
*/
|
||||
public function getTemplateForLayout(string $layout): string {
|
||||
$layout = trim($layout);
|
||||
$templates = $this->getTemplates();
|
||||
|
||||
if ($layout !== '' && isset($templates[$layout])) {
|
||||
$file = $templates[$layout];
|
||||
if ($this->templateExists($file)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
$config = $this->getConfig();
|
||||
$default = $config['default_template'] ?? 'full_content';
|
||||
if (isset($templates[$default])) {
|
||||
$file = $templates[$default];
|
||||
if ($this->templateExists($file)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
return 'full_content.twig';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of available layout keys defined in the "template" section.
|
||||
*
|
||||
* @return array List of layout keys
|
||||
*/
|
||||
public function getLayouts(): array {
|
||||
return array_keys($this->getTemplates());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a template file exists in the theme directory
|
||||
*/
|
||||
private function templateExists(string $file): bool {
|
||||
$path = $this->themeDir . '/' . ltrim($file, '/');
|
||||
return is_file($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a layout template with the given data.
|
||||
*
|
||||
* @param string $layout Requested layout key
|
||||
* @param array $data Template variables
|
||||
* @return string Rendered HTML
|
||||
*/
|
||||
public function render(string $layout, array $data): string {
|
||||
$template = $this->getTemplateForLayout($layout);
|
||||
return $this->twig->render($template, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the theme's SCSS to CSS (cached by source mtime).
|
||||
*
|
||||
* @return string|null Absolute path to the compiled CSS, or null if none
|
||||
*/
|
||||
public function compileCss(): ?string {
|
||||
$scssFile = $this->themeDir . '/css/theme.scss';
|
||||
if (!is_file($scssFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$themeName = basename($this->themeDir);
|
||||
$outDir = $this->compiledCssDir . '/' . $themeName;
|
||||
$outFile = $outDir . '/theme.css';
|
||||
$cacheFile = $outDir . '/.mtime';
|
||||
|
||||
$mtime = filemtime($scssFile);
|
||||
if (is_file($outFile) && is_file($cacheFile) && (int)file_get_contents($cacheFile) === $mtime) {
|
||||
return $outFile;
|
||||
}
|
||||
|
||||
if (!is_dir($outDir)) {
|
||||
mkdir($outDir, 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
$compiler = new Compiler();
|
||||
$compiler->setImportPaths($this->themeDir . '/css');
|
||||
$css = $compiler->compileString(file_get_contents($scssFile))->getCss();
|
||||
file_put_contents($outFile, $css);
|
||||
file_put_contents($cacheFile, (string)$mtime);
|
||||
return $outFile;
|
||||
} catch (\Throwable $e) {
|
||||
error_log('ThemeManager SCSS compile error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for the compiled theme CSS, or null if unavailable.
|
||||
*/
|
||||
public function getCssUrl(): ?string {
|
||||
$compiled = $this->compileCss();
|
||||
if ($compiled === null) {
|
||||
return null;
|
||||
}
|
||||
return '/themes/' . basename($this->themeDir) . '/theme.css';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for the theme JS, or null if unavailable.
|
||||
*/
|
||||
public function getJsUrl(): ?string {
|
||||
$jsFile = $this->themeDir . '/js/theme.js';
|
||||
if (!is_file($jsFile)) {
|
||||
return null;
|
||||
}
|
||||
return '/themes/' . basename($this->themeDir) . '/js/theme.js';
|
||||
}
|
||||
}
|
||||
+53
-8
@@ -12,7 +12,6 @@ if (!file_exists($configJsonPath)) {
|
||||
$defaultConfig = [
|
||||
'site_title' => 'CodePress',
|
||||
'content_dir' => 'content',
|
||||
'templates_dir' => 'cms/templates',
|
||||
'active_theme' => 'default',
|
||||
'default_page' => 'auto',
|
||||
'language' => [
|
||||
@@ -25,7 +24,7 @@ if (!file_exists($configJsonPath)) {
|
||||
],
|
||||
'author' => [
|
||||
'name' => 'E. Noorlander',
|
||||
'website' => 'https://noorlander.info'
|
||||
'website' => 'noorlander.info'
|
||||
],
|
||||
'show_version' => true,
|
||||
'enabled_plugins' => ['MQTTTracker', 'HTMLBlock'],
|
||||
@@ -54,7 +53,29 @@ if (!file_exists($configJsonPath)) {
|
||||
'geoip_api_url' => '',
|
||||
'geoip_api_key' => '',
|
||||
'retention_days' => 400,
|
||||
'excluded_ips' => []
|
||||
'excluded_ips' => [
|
||||
'192.168.0.0/16',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'127.0.0.1',
|
||||
'::1'
|
||||
]
|
||||
],
|
||||
'logging' => [
|
||||
'enabled' => true,
|
||||
'driver' => 'sqlite',
|
||||
'syslog_host' => '',
|
||||
'syslog_port' => 514,
|
||||
'syslog_facility' => 'local0',
|
||||
'syslog_ident' => 'codepress',
|
||||
'events' => [
|
||||
'admin' => true,
|
||||
'requests' => true,
|
||||
'errors' => true,
|
||||
'security' => true,
|
||||
'content' => true,
|
||||
'system' => true
|
||||
]
|
||||
]
|
||||
];
|
||||
@file_put_contents($configJsonPath, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
@@ -88,25 +109,50 @@ if (file_exists($configJsonPath)) {
|
||||
'geoip_api_url' => '',
|
||||
'geoip_api_key' => '',
|
||||
'retention_days' => 400,
|
||||
'excluded_ips' => [],
|
||||
'excluded_ips' => [
|
||||
'192.168.0.0/16',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'127.0.0.1',
|
||||
'::1'
|
||||
],
|
||||
],
|
||||
'logging' => [
|
||||
'enabled' => true,
|
||||
'driver' => 'sqlite',
|
||||
'syslog_host' => '',
|
||||
'syslog_port' => 514,
|
||||
'syslog_facility' => 'local0',
|
||||
'syslog_ident' => 'codepress',
|
||||
'events' => [
|
||||
'admin' => true,
|
||||
'requests' => true,
|
||||
'errors' => true,
|
||||
'security' => true,
|
||||
'content' => true,
|
||||
'system' => true
|
||||
],
|
||||
],
|
||||
];
|
||||
foreach ($sectionDefaults as $section => $defaults) {
|
||||
$config[$section] = array_merge($defaults, is_array($config[$section] ?? null) ? $config[$section] : []);
|
||||
}
|
||||
|
||||
// Ensure the private/loopback IP defaults are present when the list is empty
|
||||
if (empty($config['analytics']['excluded_ips'])) {
|
||||
$config['analytics']['excluded_ips'] = $sectionDefaults['analytics']['excluded_ips'];
|
||||
}
|
||||
|
||||
// Convert relative paths to absolute
|
||||
$projectRoot = __DIR__ . '/../../';
|
||||
if (isset($config['content_dir']) && strpos($config['content_dir'], '/') !== 0) {
|
||||
$config['content_dir'] = $projectRoot . $config['content_dir'];
|
||||
}
|
||||
if (isset($config['templates_dir']) && strpos($config['templates_dir'], '/') !== 0) {
|
||||
$config['templates_dir'] = $projectRoot . $config['templates_dir'];
|
||||
}
|
||||
|
||||
// Load active theme
|
||||
$activeTheme = $config['active_theme'] ?? 'default';
|
||||
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
|
||||
$config['theme_dir'] = $themeDir;
|
||||
$themeFile = $themeDir . '/theme.json';
|
||||
if (file_exists($themeFile)) {
|
||||
$themeConfig = json_decode(file_get_contents($themeFile), true);
|
||||
@@ -123,6 +169,5 @@ if (file_exists($configJsonPath)) {
|
||||
return [
|
||||
'site_title' => 'CodePress',
|
||||
'content_dir' => __DIR__ . '/../../content',
|
||||
'templates_dir' => __DIR__ . '/../templates',
|
||||
'default_page' => 'auto'
|
||||
];
|
||||
@@ -40,9 +40,11 @@ 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
|
||||
require_once 'class/Logger.php';
|
||||
require_once 'class/LogManager.php';
|
||||
|
||||
// Load Plugin system
|
||||
require_once 'plugin/CMSAPI.php';
|
||||
|
||||
@@ -30,6 +30,26 @@ if (is_file($filePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Serve theme assets from the themes/ directory (e.g. /themes/default/js/theme.js)
|
||||
if (preg_match('#^/themes/([^/]+)/(.+)$#', $path, $m)) {
|
||||
$themeName = $m[1];
|
||||
$themeRel = $m[2];
|
||||
$themesDir = __DIR__ . '/../themes';
|
||||
$themeFile = $themesDir . '/' . $themeName . '/' . $themeRel;
|
||||
$realThemes = realpath($themesDir);
|
||||
$realFile = realpath($themeFile);
|
||||
if ($realFile && $realThemes && strpos($realFile, $realThemes) === 0 && is_file($realFile)) {
|
||||
$ext = strtolower(pathinfo($realFile, PATHINFO_EXTENSION));
|
||||
if (isset($mimeTypes[$ext])) {
|
||||
header('Content-Type: ' . $mimeTypes[$ext]);
|
||||
}
|
||||
readfile($realFile);
|
||||
return true;
|
||||
}
|
||||
http_response_code(404);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin routes: /admin/login → admin.php?route=login
|
||||
if (preg_match('#^/admin(?:/(.+))?$#', $path, $m)) {
|
||||
$_GET['route'] = $m[1] ?? 'dashboard';
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<footer class="bg-light border-top py-1">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
|
||||
<div class="file-info mb-2 mb-md-0">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-file-text footer-icon" title="{{t_file_details}}: {{page_title}}"></i>
|
||||
<span class="page-title d-none d-lg-inline" title="{{page_title}}">{{page_title}}</span>
|
||||
{{#file_info_block}}
|
||||
<span class="ms-2">
|
||||
{{#show_created}}
|
||||
<i class="bi bi-calendar-plus footer-icon" title="{{t_created}}: {{created}}"></i>
|
||||
<span class="file-created me-1" title="{{t_created}}: {{created}}">{{created}}</span>
|
||||
{{/show_created}}
|
||||
<i class="bi bi-calendar-check footer-icon" title="{{t_modified}}: {{modified}}"></i>
|
||||
<span class="file-modified" title="{{t_modified}}: {{modified}}">{{modified}}</span>
|
||||
</span>
|
||||
{{/file_info_block}}
|
||||
</small>
|
||||
</div>
|
||||
<div class="site-info">
|
||||
<small class="text-muted">
|
||||
<a href="/{{current_lang}}/guide" class="footer-icon guide" title="{{t_guide}}">
|
||||
<i class="bi bi-book"></i>
|
||||
</a>
|
||||
<span class="ms-1">|</span>
|
||||
{{#cms_version}}
|
||||
<span class="ms-1 cms-version text-muted">{{cms_version}}</span>
|
||||
{{/cms_version}}
|
||||
<a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener noreferrer" class="footer-icon cms ms-1" title="{{t_powered_by}} CodePress CMS">
|
||||
<i class="bi bi-cpu"></i>
|
||||
</a>
|
||||
<span class="ms-1">|</span>
|
||||
<a href="{{author_website}}" target="_blank" rel="noopener noreferrer" class="footer-icon website" title="{{t_author_website}}">
|
||||
<i class="bi bi-globe"></i>
|
||||
</a>
|
||||
<span class="ms-1">|</span>
|
||||
<a href="{{author_git}}" target="_blank" rel="noopener noreferrer" class="footer-icon git" title="{{t_author_git}}">
|
||||
<i class="bi bi-git"></i>
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1,75 +0,0 @@
|
||||
<header id="site-header" class="navbar navbar-expand-lg navbar-dark" style="background-color: transparent;">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/{{current_lang}}">
|
||||
<img src="/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2">
|
||||
{{site_title}}
|
||||
</a>
|
||||
|
||||
<!-- Desktop search and language -->
|
||||
<div class="d-none d-lg-flex ms-auto align-items-center">
|
||||
<form class="d-flex me-3" method="GET" action="" role="search" aria-label="Site search">
|
||||
<div class="form-group">
|
||||
<label for="desktop-search-input" class="sr-only">{{t_search_placeholder}}</label>
|
||||
<input class="form-control me-2 search-input" type="search" id="desktop-search-input" name="search" placeholder="{{t_search_placeholder}}" value="{{search_query}}" aria-describedby="search-help">
|
||||
<div id="search-help" class="sr-only">Enter keywords to search through the documentation</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-light" type="submit" aria-label="{{t_search_button}}">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{t_search_button}}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Language switcher -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{current_lang_upper}}">
|
||||
{{current_lang_upper}} <i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" role="menu">
|
||||
{{#available_langs}}
|
||||
<li role="none">
|
||||
<a class="dropdown-item {{#is_current}}active{{/is_current}}" href="{{url}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}">
|
||||
{{native_name}}
|
||||
</a>
|
||||
</li>
|
||||
{{/available_langs}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile search and language toggle -->
|
||||
<div class="d-lg-none">
|
||||
<button class="btn btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#mobileSearch" aria-controls="mobileSearch" aria-expanded="false" aria-label="Toggle search">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{current_lang_upper}}">
|
||||
{{current_lang_upper}} <i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" role="menu">
|
||||
{{#available_langs}}
|
||||
<li role="none">
|
||||
<a class="dropdown-item {{#is_current}}active{{/is_current}}" href="{{url}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}">
|
||||
{{native_name}}
|
||||
</a>
|
||||
</li>
|
||||
{{/available_langs}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile search bar -->
|
||||
<div class="collapse navbar-collapse d-lg-none" id="mobileSearch">
|
||||
<div class="container-fluid px-0">
|
||||
<form class="d-flex px-3 pb-3" method="GET" action="" role="search" aria-label="Site search">
|
||||
<div class="form-group w-100">
|
||||
<label for="mobile-search-input" class="sr-only">{{t_search_placeholder}}</label>
|
||||
<input class="form-control me-2 search-input" type="search" id="mobile-search-input" name="search" placeholder="{{t_search_placeholder}}" value="{{search_query}}" aria-describedby="mobile-search-help">
|
||||
<div id="mobile-search-help" class="sr-only">Enter keywords to search through the documentation</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-light" type="submit" aria-label="{{t_search_button}}">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{t_search_button}}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,17 +0,0 @@
|
||||
<nav class="navigation-section" role="navigation" aria-label="Main navigation">
|
||||
<h2 class="sr-only">Site Navigation</h2>
|
||||
<div class="container-fluid">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<ul class="nav nav-tabs flex-wrap" role="menubar">
|
||||
<li class="nav-item" role="none">
|
||||
<a class="nav-link {{home_active_class}}" href="/{{current_lang}}" role="menuitem" aria-current="{{#is_homepage}}page{{/is_homepage}}">
|
||||
<i class="bi bi-house" aria-hidden="true"></i> {{homepage_title}}
|
||||
</a>
|
||||
</li>
|
||||
{{{menu}}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="html-content">
|
||||
<article class="content-body" role="main">
|
||||
{{{content}}}
|
||||
</article>
|
||||
</div>
|
||||
@@ -1,552 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{current_lang}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{page_title}} - {{site_title}}</title>
|
||||
|
||||
<!-- Skip to content link for accessibility -->
|
||||
<a href="#main-content" class="skip-link sr-only sr-only-focusable">Skip to main content</a>
|
||||
|
||||
<!-- CMS Meta Tags -->
|
||||
<meta name="generator" content="{{site_title}} CMS">
|
||||
<meta name="application-name" content="{{site_title}}">
|
||||
<meta name="author" content="{{author_name}}">
|
||||
<meta name="creator" content="{{author_name}}">
|
||||
<meta name="publisher" content="{{author_name}}">
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="{{seo_description}}">
|
||||
<meta name="keywords" content="{{seo_keywords}}">
|
||||
{{#block_ai_bots}}
|
||||
<meta name="robots" content="noai, noimageai">
|
||||
<meta name="tdm-reservation" content="1">
|
||||
{{/block_ai_bots}}
|
||||
{{#block_search_engines}}
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
{{/block_search_engines}}
|
||||
|
||||
<!-- Author Links -->
|
||||
<link rel="author" href="{{author_website}}">
|
||||
<link rel="me" href="{{author_git}}">
|
||||
|
||||
<!-- Favicon and PWA -->
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0a369d">
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/css/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/assets/css/style.css" rel="stylesheet">
|
||||
<link href="/assets/css/mobile.css" rel="stylesheet">
|
||||
|
||||
<!-- Accessibility styles -->
|
||||
<style>
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
text-decoration: none;
|
||||
z-index: 100;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 6px;
|
||||
outline: 3px solid #0056b3;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.sr-only-focusable:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: inherit;
|
||||
margin: inherit;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Dynamic theme colors -->
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#site-header {
|
||||
background-image: {{background_image_css}};
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#site-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--header-bg);
|
||||
opacity: calc((100 - {{background_image_opacity}}) / 100);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#site-header > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:root {
|
||||
--header-bg: {{header_color}};
|
||||
--header-font: {{header_font_color}};
|
||||
--header-height: {{header_height}}px;
|
||||
--nav-bg: {{navigation_color}};
|
||||
--nav-font: {{navigation_font_color}};
|
||||
--nav-height: {{nav_height}}px;
|
||||
--sidebar-bg: {{sidebar_background}};
|
||||
--sidebar-border: {{sidebar_border}};
|
||||
}
|
||||
|
||||
/* Header styles */
|
||||
.navbar {
|
||||
background-color: var(--header-bg) !important;
|
||||
min-height: var(--header-height);
|
||||
}
|
||||
|
||||
.navbar .navbar-brand,
|
||||
.navbar .navbar-text,
|
||||
.navbar .form-control,
|
||||
.navbar .btn {
|
||||
color: var(--header-font) !important;
|
||||
}
|
||||
|
||||
.navbar .form-control::placeholder {
|
||||
color: rgba(255,255,255,0.7) !important;
|
||||
}
|
||||
|
||||
.navbar .btn-outline-light {
|
||||
border-color: var(--header-font) !important;
|
||||
}
|
||||
|
||||
/* Language dropdown styling */
|
||||
.dropdown-menu {
|
||||
background-color: var(--header-bg) !important;
|
||||
border: 1px solid var(--header-font) !important;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--header-font) !important;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: rgba(255,255,255,0.1) !important;
|
||||
color: var(--header-font) !important;
|
||||
}
|
||||
|
||||
/* Hide Bootstrap dropdown arrow and use custom icon */
|
||||
.dropdown-toggle::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
color: var(--header-font) !important;
|
||||
border-color: var(--header-font) !important;
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
background-color: rgba(255,255,255,0.1) !important;
|
||||
color: var(--header-font) !important;
|
||||
}
|
||||
|
||||
/* Fix button color when dropdown is open */
|
||||
.btn-outline-light:focus,
|
||||
.btn-outline-light:active,
|
||||
.show > .btn-outline-light.dropdown-toggle {
|
||||
background-color: rgba(255,255,255,0.1) !important;
|
||||
color: var(--header-font) !important;
|
||||
border-color: var(--header-font) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.bi-chevron-down {
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Remove Bootstrap default breadcrumb separators */
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
content: "" !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Custom breadcrumb styling */
|
||||
.breadcrumb {
|
||||
--bs-breadcrumb-divider: "";
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--nav-font) !important;
|
||||
}
|
||||
|
||||
.breadcrumb-item a {
|
||||
color: var(--nav-font) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Sidebar toggle button in breadcrumb */
|
||||
.sidebar-toggle-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-toggle-btn {
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 1.1rem;
|
||||
color: var(--header-bg) !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-toggle-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Sidebar hide/show transition */
|
||||
.sidebar-column {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Navigation section background */
|
||||
.navigation-section {
|
||||
background-color: var(--nav-bg) !important;
|
||||
color: var(--nav-font) !important;
|
||||
min-height: var(--nav-height);
|
||||
}
|
||||
|
||||
/* Enhanced accessibility styles */
|
||||
.focus-visible:focus,
|
||||
.btn:focus,
|
||||
.form-control:focus,
|
||||
.nav-link:focus {
|
||||
outline: 3px solid #0056b3 !important;
|
||||
outline-offset: 2px !important;
|
||||
box-shadow: 0 0 0 1px #ffffff, 0 0 0 4px #0056b3 !important;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--text-color: #000000;
|
||||
--bg-color: #ffffff;
|
||||
--border-color: #000000;
|
||||
--focus-color: #000000;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: #ffffff !important;
|
||||
border-bottom: 1px solid #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove nav-tabs background so it inherits from parent */
|
||||
.nav-tabs {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
color: var(--nav-font) !important;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1) !important;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
background-color: rgba(255,255,255,0.2) !important;
|
||||
border-bottom: 2px solid var(--nav-font) !important;
|
||||
}
|
||||
|
||||
/* Sidebar styling */
|
||||
.sidebar-column {
|
||||
background-color: var(--sidebar-bg) !important;
|
||||
border-right: 1px solid var(--sidebar-border) !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
min-height: calc(100vh - var(--header-height) - var(--nav-height) - 42px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 1.5rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
padding-bottom: 80px !important;
|
||||
}
|
||||
|
||||
/* Ensure full height layout */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar-column {
|
||||
border-right: none !important;
|
||||
border-top: 1px solid var(--sidebar-border) !important;
|
||||
min-height: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
min-height: auto;
|
||||
padding-bottom: 2rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet and mobile: sidebar below content */
|
||||
@media (max-width: 991.98px) {
|
||||
.sidebar-column {
|
||||
order: 2 !important;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
order: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code block styling */
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
code {
|
||||
background: #e8e8e8;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
color: #d63384;
|
||||
}
|
||||
|
||||
/* Footer icon hover effects */
|
||||
.footer-icon {
|
||||
color: #6c757d;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
display: inline-block;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.footer-icon:hover {
|
||||
color: #0d6efd;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer-icon:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Specific icon hover colors */
|
||||
.footer-icon.guide:hover {
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
.footer-icon.cms:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.footer-icon.git:hover {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.footer-icon.website:hover {
|
||||
color: #fd7e14;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{>header}}
|
||||
|
||||
<nav role="navigation" aria-label="Main navigation" id="site-navigation">
|
||||
{{>navigation}}
|
||||
</nav>
|
||||
|
||||
<nav id="site-breadcrumb" class="breadcrumb-section bg-light border-bottom" aria-label="Breadcrumb navigation">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 py-2">
|
||||
<h2 class="sr-only">Breadcrumb Navigation</h2>
|
||||
{{{breadcrumb}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main role="main" id="main-content" class="main-content" style="padding: 0;">
|
||||
{{#sidebar_content}}
|
||||
{{#equal layout "sidebar-content"}}
|
||||
<div class="row g-0">
|
||||
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2 order-md-1">
|
||||
<div class="sidebar h-100">
|
||||
{{{sidebar_content}}}
|
||||
</div>
|
||||
</aside>
|
||||
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1 order-md-2">
|
||||
<div class="content-wrapper p-4">
|
||||
{{>content_template}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{/equal}}
|
||||
|
||||
{{#equal layout "content"}}
|
||||
<div class="container">
|
||||
<section id="site-content" class="col-12">
|
||||
<div class="content-wrapper p-4">
|
||||
{{>content_template}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{/equal}}
|
||||
|
||||
{{#equal layout "sidebar"}}
|
||||
<div class="container-fluid">
|
||||
<aside id="site-sidebar" class="col-12 sidebar-column">
|
||||
<div class="sidebar">
|
||||
{{{sidebar_content}}}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{{/equal}}
|
||||
|
||||
{{#equal layout "content-sidebar"}}
|
||||
<div class="row g-0">
|
||||
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1">
|
||||
<div class="content-wrapper p-4">
|
||||
{{>content_template}}
|
||||
</div>
|
||||
</section>
|
||||
<aside id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2">
|
||||
<div class="sidebar h-100">
|
||||
{{{sidebar_content}}}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{{/equal}}
|
||||
|
||||
{{#equal layout "content-sidebar-reverse"}}
|
||||
<div class="row g-0 flex-row-reverse">
|
||||
<section id="site-content" class="col-lg-9 col-md-8 content-column">
|
||||
<div class="content-wrapper p-4">
|
||||
{{>content_template}}
|
||||
</div>
|
||||
</section>
|
||||
<aside id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column">
|
||||
<div class="sidebar h-100">
|
||||
{{{sidebar_content}}}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{{/equal}}
|
||||
{{/sidebar_content}}
|
||||
{{^sidebar_content}}
|
||||
<div class="container">
|
||||
<section id="site-content" class="col-12">
|
||||
<div class="content-wrapper p-4">
|
||||
{{>content_template}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{/sidebar_content}}
|
||||
</main>
|
||||
|
||||
<footer role="contentinfo" id="site-footer">
|
||||
{{>footer}}
|
||||
</footer>
|
||||
|
||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="markdown-content">
|
||||
<article class="content-body" role="main">
|
||||
{{{content}}}
|
||||
</article>
|
||||
</div>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="php-content">
|
||||
<article class="content-body" role="main">
|
||||
{{{content}}}
|
||||
</article>
|
||||
</div>
|
||||
Reference in New Issue
Block a user