v2.5.1: Admin theme refactor, Navigation plugin, user roles, guide restructure

- Reorganize admin into admin/theme/default/ (views + assets)
- Rename GuideNav to Navigation plugin (essential, protected)
- Plugin assets support (SCSS/CSS) loaded after theme CSS
- User roles: Admin, Content Manager, BI Manager, Site Admin
- Role-based access control (RBAC) for admin routes and sidebar
- Guide restructure: sub-topics in separate folders with sidebar nav
- Dynamic breadcrumb for homepage and subdirectories
- Fix theme path traversal (../../ -> ../) in admin.php
- Fix CodeMirror mode load order (xml -> css -> js -> htmlmixed -> php)
- Fix editor-toolbar.js null checks for plugin edit pages
- Layout select from theme.json with live frontmatter update
- Footer sticky at bottom of viewport (min-height: 100vh)
- Breadcrumb color fix (var(--nav-font) -> var(--header-bg))
- Remove language switcher from guide pages
- Update README.md and README.en.md
- Bump version to 2.5.1
This commit is contained in:
2026-08-10 15:36:29 +02:00
parent 7fc3847bbb
commit 3a55ea4db6
210 changed files with 11033 additions and 9140 deletions
+118 -16
View File
@@ -11,7 +11,7 @@ use Twig\Loader\FilesystemLoader;
* - 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)
* - Compile theme SCSS to CSS (in assets/css_compiled/)
* - Map a requested layout to a concrete .twig template, falling back
* to the theme's default_layout when the layout is unknown
*/
@@ -20,7 +20,6 @@ class ThemeManager {
private $themeDir;
private $themeConfig;
private $twig;
private $compiledCssDir;
/**
* @param array $config Full CMS config (must contain 'theme_dir' and 'theme')
@@ -29,7 +28,6 @@ class ThemeManager {
$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, [
@@ -143,22 +141,23 @@ class ThemeManager {
/**
* Compile the theme's SCSS to CSS (cached by source mtime).
* Compiles from assets/scss/theme.scss to assets/css_compiled/theme.css
*
* @param bool $force Force recompilation
* @return string|null Absolute path to the compiled CSS, or null if none
*/
public function compileCss(): ?string {
$scssFile = $this->themeDir . '/css/theme.scss';
public function compileCss(bool $force = false): ?string {
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
if (!is_file($scssFile)) {
return null;
}
$themeName = basename($this->themeDir);
$outDir = $this->compiledCssDir . '/' . $themeName;
$outDir = $this->themeDir . '/assets/css_compiled';
$outFile = $outDir . '/theme.css';
$cacheFile = $outDir . '/.mtime';
$mtime = filemtime($scssFile);
if (is_file($outFile) && is_file($cacheFile) && (int)file_get_contents($cacheFile) === $mtime) {
if (!$force && is_file($outFile) && is_file($cacheFile) && (int)file_get_contents($cacheFile) === $mtime) {
return $outFile;
}
@@ -168,7 +167,7 @@ class ThemeManager {
try {
$compiler = new Compiler();
$compiler->setImportPaths($this->themeDir . '/css');
$compiler->setImportPaths($this->themeDir . '/assets/scss');
$css = $compiler->compileString(file_get_contents($scssFile))->getCss();
file_put_contents($outFile, $css);
file_put_contents($cacheFile, (string)$mtime);
@@ -180,24 +179,127 @@ class ThemeManager {
}
/**
* Get the public URL for the compiled theme CSS, or null if unavailable.
* Get the public URL for the theme CSS.
* Priority: 1) assets/css/theme.css (manual), 2) assets/css_compiled/theme.css (compiled), 3) null
*/
public function getCssUrl(): ?string {
$compiled = $this->compileCss();
if ($compiled === null) {
return null;
$manualCss = $this->themeDir . '/assets/css/theme.css';
$compiledCss = $this->themeDir . '/assets/css_compiled/theme.css';
if (is_file($manualCss)) {
return $this->getThemeAssetUrl('css/theme.css');
}
return '/themes/' . basename($this->themeDir) . '/theme.css';
if (is_file($compiledCss)) {
return $this->getThemeAssetUrl('css_compiled/theme.css');
}
$compiled = $this->compileCss();
if ($compiled !== null) {
return $this->getThemeAssetUrl('css_compiled/theme.css');
}
return null;
}
/**
* Get the public URL for the theme JS, or null if unavailable.
*/
public function getJsUrl(): ?string {
$jsFile = $this->themeDir . '/js/theme.js';
$jsFile = $this->themeDir . '/assets/js/theme.js';
if (!is_file($jsFile)) {
return null;
}
return '/themes/' . basename($this->themeDir) . '/js/theme.js';
return $this->getThemeAssetUrl('js/theme.js');
}
/**
* Get public URL for a theme asset.
*/
private function getThemeAssetUrl(string $assetPath): string {
$themeName = basename($this->themeDir);
return '/themes/' . $themeName . '/assets/' . $assetPath;
}
/**
* Check if theme has SCSS source file.
*/
public function hasScss(): bool {
return is_file($this->themeDir . '/assets/scss/theme.scss');
}
/**
* Check if compiled CSS is newer than SCSS source.
*/
public function isScssCompiled(): bool {
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
$cssFile = $this->themeDir . '/assets/css_compiled/theme.css';
if (!is_file($scssFile) || !is_file($cssFile)) {
return false;
}
return filemtime($cssFile) >= filemtime($scssFile);
}
/**
* Check if theme has manual CSS file.
*/
public function hasManualCss(): bool {
return is_file($this->themeDir . '/assets/css/theme.css');
}
/**
* Get list of CSS files in theme assets/css/ directory.
* Excludes css_compiled directory.
*/
public function getCssFiles(): array {
$cssDir = $this->themeDir . '/assets/css';
$files = [];
if (!is_dir($cssDir)) {
return $files;
}
foreach (scandir($cssDir) as $file) {
if ($file[0] === '.') continue;
if (pathinfo($file, PATHINFO_EXTENSION) === 'css') {
$files[] = $this->getThemeAssetUrl('css/' . $file);
}
}
return $files;
}
/**
* Get list of JS files in theme assets/js/ directory.
*/
public function getJsFiles(): array {
$jsDir = $this->themeDir . '/assets/js';
$files = [];
if (!is_dir($jsDir)) {
return $files;
}
foreach (scandir($jsDir) as $file) {
if ($file[0] === '.') continue;
if (pathinfo($file, PATHINFO_EXTENSION) === 'js') {
$files[] = $this->getThemeAssetUrl('js/' . $file);
}
}
return $files;
}
/**
* Get URL for favicon if it exists.
*/
public function getFaviconUrl(): ?string {
$faviconFile = $this->themeDir . '/assets/img/favicon.svg';
if (!is_file($faviconFile)) {
return null;
}
return $this->getThemeAssetUrl('img/favicon.svg');
}
}