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:
+110
-20
@@ -658,14 +658,16 @@ class CodePressCMS {
|
||||
'allow_unsafe_links' => false,
|
||||
'max_nesting_level' => 100,
|
||||
'heading_permalink' => [
|
||||
'symbol' => '',
|
||||
'aria_hidden' => true,
|
||||
'html_class' => 'heading-permalink',
|
||||
'id_prefix' => '',
|
||||
'fragment_prefix' => '',
|
||||
'apply_id_to_heading' => true,
|
||||
'insert' => 'after',
|
||||
'min_heading_level' => 1,
|
||||
'max_heading_level' => 6,
|
||||
'title' => 'Permalink',
|
||||
'symbol' => '',
|
||||
'aria_hidden' => true,
|
||||
],
|
||||
'external_link' => [
|
||||
'internal_hosts' => $this->getInternalHosts(),
|
||||
@@ -987,20 +989,64 @@ class CodePressCMS {
|
||||
*/
|
||||
private function getGuidePage() {
|
||||
$lang = $this->currentLanguage;
|
||||
$guideFile = __DIR__ . '/../../../guide/' . $lang . '.codepress.md';
|
||||
$pagePath = $_GET['page'] ?? '';
|
||||
// Special case: /nl/guide sets page='guide', but we want index
|
||||
if ($pagePath === 'guide') {
|
||||
$pagePath = '';
|
||||
}
|
||||
$rootDir = dirname(__DIR__, 3);
|
||||
$guideDir = $rootDir . '/guide/' . $lang;
|
||||
|
||||
// Get guide file
|
||||
if (empty($pagePath)) {
|
||||
$guideFile = $guideDir . '/index.md';
|
||||
} else {
|
||||
$pagePath = trim($pagePath, '/');
|
||||
$guideFile = $guideDir . '/' . $pagePath . '.md';
|
||||
}
|
||||
|
||||
// Fallback to English
|
||||
if (!file_exists($guideFile) && $lang !== 'en') {
|
||||
$guideFile = $rootDir . '/guide/en/' . (empty($pagePath) ? 'index.md' : $pagePath . '.md');
|
||||
}
|
||||
|
||||
// Handle 404
|
||||
if (!file_exists($guideFile)) {
|
||||
$guideFile = __DIR__ . '/../../../guide/en.codepress.md'; // Fallback to English
|
||||
http_response_code(404);
|
||||
return [
|
||||
'title' => 'Handleiding niet gevonden',
|
||||
'content' => '<p>De gevraagde handleiding is niet gevonden.</p>',
|
||||
'layout' => 'content',
|
||||
'guide_breadcrumbs' => [],
|
||||
'guide_lang' => $lang,
|
||||
'guide_page' => $pagePath,
|
||||
];
|
||||
}
|
||||
|
||||
$content = file_get_contents($guideFile);
|
||||
|
||||
// Reuse parseMarkdown to avoid duplicating CommonMark setup
|
||||
$result = $this->parseMarkdown($content, $guideFile);
|
||||
|
||||
// Override title for guide
|
||||
$result['title'] = $this->t('manual') . ' - CodePress CMS';
|
||||
$result['layout'] = $result['metadata']['layout'] ?? 'content';
|
||||
// Build breadcrumbs
|
||||
$guideBase = '/' . $lang . '/guide';
|
||||
$breadcrumbs = [['title' => $this->t('manual'), 'url' => $guideBase]];
|
||||
if (!empty($pagePath)) {
|
||||
$parts = explode('/', $pagePath);
|
||||
$buildPath = '';
|
||||
foreach ($parts as $part) {
|
||||
$buildPath .= ($buildPath ? '/' : '') . $part;
|
||||
$breadcrumbs[] = [
|
||||
'title' => str_replace('-', ' ', ucfirst($part)),
|
||||
'url' => $guideBase . '?page=' . $buildPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result['title'] = ($result['metadata']['title'] ?? $this->t('manual')) . ' - CodePress CMS';
|
||||
$result['layout'] = 'guide';
|
||||
$result['metadata']['plugins'] = 'Navigation';
|
||||
$result['guide_breadcrumbs'] = $breadcrumbs;
|
||||
$result['guide_lang'] = $lang;
|
||||
$result['guide_page'] = $pagePath;
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -1148,8 +1194,9 @@ class CodePressCMS {
|
||||
$layout = $page['layout'] ?? 'sidebar-content';
|
||||
|
||||
// Determine if sidebar toggle should be shown
|
||||
$isGuidePage = isset($_GET['guide']) || ($page['layout'] ?? '') === 'guide';
|
||||
$hasSidebar = $layout !== 'content' && !empty(trim($sidebarContent));
|
||||
$breadcrumb = $this->generateBreadcrumb($hasSidebar);
|
||||
$breadcrumb = $this->generateBreadcrumb($hasSidebar, $isGuidePage ? $page : null);
|
||||
|
||||
// Prepare template data
|
||||
$templateData = [
|
||||
@@ -1247,6 +1294,13 @@ class CodePressCMS {
|
||||
// Don't show site title link on guide page
|
||||
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
|
||||
|
||||
// Pass guide-specific data to template
|
||||
if (isset($page['guide_breadcrumbs'])) {
|
||||
$templateData['guide_breadcrumbs'] = $page['guide_breadcrumbs'];
|
||||
$templateData['guide_lang'] = $page['guide_lang'] ?? $this->currentLanguage;
|
||||
$templateData['guide_page'] = $page['guide_page'] ?? '';
|
||||
}
|
||||
|
||||
// Map legacy frontmatter layout values to theme template keys
|
||||
$layoutKey = $this->mapLayoutToThemeKey($layout);
|
||||
|
||||
@@ -1256,6 +1310,13 @@ class CodePressCMS {
|
||||
$templateData['theme_css_url'] = $themeManager->getCssUrl();
|
||||
$templateData['theme_js_url'] = $themeManager->getJsUrl();
|
||||
$templateData['theme_config'] = $themeManager->getConfig();
|
||||
$templateData['theme_base_url'] = '/themes/' . basename($themeManager->getThemeDir());
|
||||
$templateData['theme_css_files'] = $themeManager->getCssFiles();
|
||||
$templateData['theme_js_files'] = $themeManager->getJsFiles();
|
||||
$templateData['theme_favicon'] = $themeManager->getFaviconUrl();
|
||||
|
||||
// Plugin CSS (loaded after theme CSS so theme can override)
|
||||
$templateData['plugin_css_urls'] = $this->pluginManager->getPluginCssUrls();
|
||||
|
||||
// Render the page through the active theme
|
||||
$renderedLayout = $themeManager->render($layoutKey, $templateData);
|
||||
@@ -1269,15 +1330,46 @@ class CodePressCMS {
|
||||
* Generate breadcrumb navigation HTML
|
||||
*
|
||||
* @param bool $hasSidebar Whether sidebar content exists and should show toggle
|
||||
* @param array|null $guidePage Guide page data (when on a guide page)
|
||||
* @return string Breadcrumb HTML
|
||||
*/
|
||||
public function generateBreadcrumb($hasSidebar = true) {
|
||||
public function generateBreadcrumb($hasSidebar = true, $guidePage = null) {
|
||||
// Sidebar toggle button (shown before home icon in breadcrumb)
|
||||
$sidebarToggle = '';
|
||||
if ($hasSidebar) {
|
||||
$sidebarToggle = '<li class="breadcrumb-item sidebar-toggle-item"><button type="button" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle Sidebar" aria-label="Toggle Sidebar" aria-expanded="true"><i class="bi bi-layout-sidebar-inset"></i></button></li>';
|
||||
}
|
||||
|
||||
// Guide page breadcrumb: Home > Handleiding > [page parts]
|
||||
if ($guidePage !== null) {
|
||||
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
|
||||
$breadcrumb .= $sidebarToggle;
|
||||
$breadcrumb .= '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>';
|
||||
$breadcrumb .= '<li class="breadcrumb-item"> > </li>';
|
||||
$guidePagePath = $guidePage['guide_page'] ?? '';
|
||||
$guideLang = $guidePage['guide_lang'] ?? $this->currentLanguage;
|
||||
$guideBase = '/' . $guideLang . '/guide';
|
||||
if (empty($guidePagePath)) {
|
||||
$breadcrumb .= '<li class="breadcrumb-item active">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</li>';
|
||||
} else {
|
||||
$breadcrumb .= '<li class="breadcrumb-item"><a href="' . htmlspecialchars($guideBase, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($this->t('manual'), ENT_QUOTES, 'UTF-8') . '</a></li>';
|
||||
$parts = explode('/', $guidePagePath);
|
||||
$buildPath = '';
|
||||
foreach ($parts as $i => $part) {
|
||||
$buildPath .= ($buildPath ? '/' : '') . $part;
|
||||
$title = htmlspecialchars(str_replace('-', ' ', ucfirst($part)), ENT_QUOTES, 'UTF-8');
|
||||
$url = htmlspecialchars($guideBase . '?page=' . $buildPath, ENT_QUOTES, 'UTF-8');
|
||||
if ($i === count($parts) - 1) {
|
||||
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $title . '</li>';
|
||||
} else {
|
||||
$breadcrumb .= '<li class="breadcrumb-item"> > </li><li class="breadcrumb-item"><a href="' . $url . '">' . $title . '</a></li>';
|
||||
}
|
||||
}
|
||||
}
|
||||
$breadcrumb .= '</ol></nav>';
|
||||
return $breadcrumb;
|
||||
}
|
||||
|
||||
if (isset($_GET['search'])) {
|
||||
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li><li class="breadcrumb-item"> > </li><li class="breadcrumb-item active">' . $this->t('search') . '</li></ol></nav>';
|
||||
}
|
||||
@@ -1286,18 +1378,16 @@ class CodePressCMS {
|
||||
$page = htmlspecialchars($page, ENT_QUOTES, 'UTF-8');
|
||||
$page = preg_replace('/\.[^.]+$/', '', $page);
|
||||
|
||||
// Convert page to clean URL format for breadcrumb
|
||||
if ($page === $this->getEffectiveDefaultPage()) {
|
||||
return '<nav aria-label="breadcrumb"><ol class="breadcrumb">' . $sidebarToggle . '<li class="breadcrumb-item active"><i class="bi bi-house"></i></li></ol></nav>';
|
||||
}
|
||||
$isHomepage = ($page === $this->getEffectiveDefaultPage());
|
||||
$homeUrl = '/' . $this->currentLanguage;
|
||||
|
||||
$breadcrumb = '<nav aria-label="breadcrumb"><ol class="breadcrumb">';
|
||||
|
||||
// Start with sidebar toggle, then home icon linking to default page (root)
|
||||
$breadcrumb .= $sidebarToggle;
|
||||
$breadcrumb .= '<li class="breadcrumb-item"><a href="/' . $this->currentLanguage . '"><i class="bi bi-house"></i></a></li>';
|
||||
|
||||
// Split page path and build breadcrumb items
|
||||
// Home icon: clickable link to language root (always, unless we're on the root itself)
|
||||
$breadcrumb .= '<li class="breadcrumb-item"><a href="' . $homeUrl . '"><i class="bi bi-house"></i></a></li>';
|
||||
|
||||
// Split page path and build breadcrumb items (handles subdirectories dynamically)
|
||||
$parts = explode('/', $page);
|
||||
$currentPath = '';
|
||||
|
||||
@@ -1579,4 +1669,4 @@ class CodePressCMS {
|
||||
{
|
||||
return str_replace('-/assets/', '/-assets/', $content);
|
||||
}
|
||||
}
|
||||
}// Guide fix: 1786349431
|
||||
|
||||
+118
-16
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,4 +174,23 @@ class PluginManager
|
||||
|
||||
return $sidebarContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS URLs from all enabled plugins that provide getCssUrl().
|
||||
*
|
||||
* @return array List of CSS URLs
|
||||
*/
|
||||
public function getPluginCssUrls(): array
|
||||
{
|
||||
$urls = [];
|
||||
foreach ($this->plugins as $pluginName => $plugin) {
|
||||
if (method_exists($plugin, 'getCssUrl')) {
|
||||
$url = $plugin->getCssUrl();
|
||||
if (!empty($url)) {
|
||||
$urls[] = $url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user