- 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
306 lines
9.2 KiB
PHP
306 lines
9.2 KiB
PHP
<?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 (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
|
|
*/
|
|
class ThemeManager {
|
|
private $config;
|
|
private $themeDir;
|
|
private $themeConfig;
|
|
private $twig;
|
|
|
|
/**
|
|
* @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'] ?? [];
|
|
|
|
$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).
|
|
* 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(bool $force = false): ?string {
|
|
$scssFile = $this->themeDir . '/assets/scss/theme.scss';
|
|
if (!is_file($scssFile)) {
|
|
return null;
|
|
}
|
|
|
|
$outDir = $this->themeDir . '/assets/css_compiled';
|
|
$outFile = $outDir . '/theme.css';
|
|
$cacheFile = $outDir . '/.mtime';
|
|
|
|
$mtime = filemtime($scssFile);
|
|
if (!$force && 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 . '/assets/scss');
|
|
$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 theme CSS.
|
|
* Priority: 1) assets/css/theme.css (manual), 2) assets/css_compiled/theme.css (compiled), 3) null
|
|
*/
|
|
public function getCssUrl(): ?string {
|
|
$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');
|
|
}
|
|
|
|
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 . '/assets/js/theme.js';
|
|
if (!is_file($jsFile)) {
|
|
return null;
|
|
}
|
|
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');
|
|
}
|
|
}
|