ContentAPI voor PHP content bestanden + handleiding in admin

- Nieuwe ContentAPI class beschikbaar als $api in PHP content bestanden
  met methodes: getAllPages, getPage, getMenu, getConfig, buildUrl, etc.
- Admin handleiding pagina op /admin/guide met taalwisselaar
- Zijbalk link naar handleiding in admin menu
- Dubbele alert in config pagina verwijderd
- Handleidingen (nl/en) uitgebreid met Content API referentie
This commit is contained in:
2026-07-28 13:59:32 +02:00
parent e85f6e91e1
commit 90253673ba
9 changed files with 452 additions and 25 deletions
+8
View File
@@ -76,6 +76,11 @@ $layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
<i class="bi bi-people"></i> Gebruikers <i class="bi bi-people"></i> Gebruikers
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link <?= ($route ?? '') === 'guide' ? 'active' : '' ?>" href="/admin/guide">
<i class="bi bi-book"></i> Handleiding
</a>
</li>
<li class="nav-item mt-3"> <li class="nav-item mt-3">
<a class="nav-link" href="/" target="_blank"> <a class="nav-link" href="/" target="_blank">
<i class="bi bi-box-arrow-up-right"></i> Website bekijken <i class="bi bi-box-arrow-up-right"></i> Website bekijken
@@ -152,6 +157,9 @@ $layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
case 'users': case 'users':
require __DIR__ . '/pages/users.php'; require __DIR__ . '/pages/users.php';
break; break;
case 'guide':
require __DIR__ . '/pages/guide.php';
break;
} }
?> ?>
</main> </main>
-7
View File
@@ -1,12 +1,5 @@
<h2 class="mb-4"><i class="bi bi-sliders"></i> Configuratie</h2> <h2 class="mb-4"><i class="bi bi-sliders"></i> Configuratie</h2>
<?php if ($message): ?>
<div class="alert alert-<?= $messageType ?> alert-dismissible fade show">
<?= htmlspecialchars($message) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<form method="POST" action="/admin/config"> <form method="POST" action="/admin/config">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>"> <input type="hidden" name="csrf_token" value="<?= $csrf ?>">
+24
View File
@@ -0,0 +1,24 @@
<h2 class="mb-4"><i class="bi bi-book"></i> Handleiding</h2>
<div class="mb-3">
<div class="btn-group" role="group">
<a href="/admin/guide?lang=nl" class="btn btn-sm <?= $lang === 'nl' ? 'btn-primary' : 'btn-outline-primary' ?>">Nederlands</a>
<a href="/admin/guide?lang=en" class="btn btn-sm <?= $lang === 'en' ? 'btn-primary' : 'btn-outline-primary' ?>">English</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body guide-content">
<?= $content ?>
</div>
</div>
<style>
.guide-content h2 { margin-top: 1.5rem; }
.guide-content h3 { margin-top: 1.25rem; }
.guide-content pre { background: #f4f4f4; padding: 1rem; border-radius: 4px; overflow-x: auto; }
.guide-content code { background: #f0f0f0; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; }
.guide-content table { width: 100%; margin-bottom: 1rem; }
.guide-content table th, .guide-content table td { padding: 0.5rem; border: 1px solid #dee2e6; }
.guide-content blockquote { border-left: 3px solid #ccc; padding-left: 1rem; color: #666; margin-left: 0; }
</style>
+12 -4
View File
@@ -783,7 +783,8 @@ class CodePressCMS {
$title = $metadata['title'] ?? ''; $title = $metadata['title'] ?? '';
ob_start(); ob_start();
// Make metadata available to the included file // Make API and metadata available to the included file
$api = new ContentAPI($this);
$pageMetadata = $metadata; $pageMetadata = $metadata;
include $filePath; include $filePath;
$content = ob_get_clean(); $content = ob_get_clean();
@@ -1011,7 +1012,6 @@ class CodePressCMS {
$this->pluginManager->doAction('onBeforeRender'); $this->pluginManager->doAction('onBeforeRender');
$menu = $this->getMenu(); $menu = $this->getMenu();
$breadcrumb = $this->generateBreadcrumb();
// Get homepage title // Get homepage title
$homepageTitle = $this->getHomepageTitle(); $homepageTitle = $this->getHomepageTitle();
@@ -1026,6 +1026,10 @@ class CodePressCMS {
// Get layout from page metadata // Get layout from page metadata
$layout = $page['layout'] ?? 'sidebar-content'; $layout = $page['layout'] ?? 'sidebar-content';
// Determine if sidebar toggle should be shown
$hasSidebar = $layout !== 'content' && !empty(trim($sidebarContent));
$breadcrumb = $this->generateBreadcrumb($hasSidebar);
// Prepare template data // Prepare template data
$templateData = [ $templateData = [
'site_title' => $this->config['site_title'], 'site_title' => $this->config['site_title'],
@@ -1154,11 +1158,15 @@ class CodePressCMS {
/** /**
* Generate breadcrumb navigation HTML * Generate breadcrumb navigation HTML
* *
* @param bool $hasSidebar Whether sidebar content exists and should show toggle
* @return string Breadcrumb HTML * @return string Breadcrumb HTML
*/ */
public function generateBreadcrumb() { public function generateBreadcrumb($hasSidebar = true) {
// Sidebar toggle button (shown before home icon in breadcrumb) // Sidebar toggle button (shown before home icon in breadcrumb)
$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>'; $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>';
}
if (isset($_GET['search'])) { 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>'; 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>';
+154
View File
@@ -0,0 +1,154 @@
<?php
class ContentAPI
{
private CodePressCMS $cms;
public function __construct(CodePressCMS $cms)
{
$this->cms = $cms;
}
public function getAllPages(): array
{
return $this->cms->getAllPageTitles();
}
public function getPage(string $path): ?array
{
$contentDir = $this->cms->config['content_dir'];
$path = preg_replace('/\.(md|php|html)$/', '', $path);
$filePath = $contentDir . '/' . $path;
$extensions = ['md', 'php', 'html'];
$actualPath = null;
foreach ($extensions as $ext) {
if (file_exists($filePath . '.' . $ext)) {
$actualPath = $filePath . '.' . $ext;
break;
}
}
if (!$actualPath || !file_exists($actualPath)) {
return null;
}
$content = file_get_contents($actualPath);
$extension = pathinfo($actualPath, PATHINFO_EXTENSION);
switch ($extension) {
case 'md':
$result = $this->cms->parseMarkdown($content, $actualPath);
break;
case 'php':
$result = $this->cms->parsePHP($actualPath);
$result['content'] = $this->cms->processContent($result['content']);
break;
case 'html':
$result = $this->cms->parseHTML($content, $actualPath);
break;
default:
return null;
}
return [
'title' => $result['title'] ?? '',
'content' => $result['content'] ?? '',
'path' => $path,
'layout' => $result['layout'] ?? 'sidebar-content',
'metadata' => $result['metadata'] ?? [],
];
}
public function getMenu(): array
{
return $this->cms->getMenu();
}
public function getConfig(string $key, $default = null)
{
$keys = explode('.', $key);
$value = $this->cms->config;
foreach ($keys as $k) {
if (!isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
public function getCurrentLanguage(): string
{
return $this->cms->currentLanguage;
}
public function buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string
{
$lang = $lang ?? $this->getCurrentLanguage();
$query = 'page=' . urlencode($page) . '&lang=' . urlencode($lang);
if (!empty($params)) {
$query .= '&' . http_build_query($params);
}
return '?' . $query;
}
public function pageExists(string $path): bool
{
$contentDir = $this->cms->config['content_dir'];
$path = preg_replace('/\.(md|php|html)$/', '', $path);
$basePath = $contentDir . '/' . $path;
return file_exists($basePath . '.md')
|| file_exists($basePath . '.php')
|| file_exists($basePath . '.html');
}
public function getCurrentPageTitle(): string
{
$page = $this->cms->getPage();
return $page['title'] ?? '';
}
public function getCurrentPagePath(): string
{
return $_GET['page'] ?? $this->cms->config['default_page'];
}
public function isHomepage(): bool
{
$defaultPage = $this->cms->config['default_page'] ?? 'index';
$currentPage = $_GET['page'] ?? $defaultPage;
return $currentPage === $defaultPage;
}
public function t(string $key): string
{
return $this->cms->t($key);
}
public function getSiteTitle(): string
{
return $this->cms->config['site_title'] ?? 'CodePress';
}
public function getAvailableLanguages(): array
{
return $this->cms->getAvailableLanguages();
}
public function getSearchResults(): array
{
if (isset($_GET['search'])) {
return $this->cms->searchResults;
}
return [];
}
public function isSearching(): bool
{
return isset($_GET['search']);
}
}
+3
View File
@@ -42,6 +42,9 @@ require_once 'class/Logger.php';
require_once 'plugin/CMSAPI.php'; require_once 'plugin/CMSAPI.php';
require_once 'plugin/PluginManager.php'; require_once 'plugin/PluginManager.php';
// Load ContentAPI class - provides CMS data access for PHP content files
require_once 'class/ContentAPI.php';
// Load main CMS class - handles content parsing, navigation, search, and page rendering // Load main CMS class - handles content parsing, navigation, search, and page rendering
require_once 'class/CodePressCMS.php'; require_once 'class/CodePressCMS.php';
+99 -1
View File
@@ -18,7 +18,7 @@ CodePress CMS is a lightweight, file-based content management system built with
### Content Types ### Content Types
- **Markdown (.md)** - CommonMark support via `league/commonmark` - **Markdown (.md)** - CommonMark support via `league/commonmark`
- **PHP (.php)** - Dynamic content - **PHP (.php)** - Dynamic content with **Content API** (`$api` variable)
- **HTML (.html)** - Static HTML pages - **HTML (.html)** - Static HTML pages
- **Directory listings** - Automatic directory overviews - **Directory listings** - Automatic directory overviews
- **Language-specific content** - `en.` and `nl.` prefixes - **Language-specific content** - `en.` and `nl.` prefixes
@@ -63,6 +63,7 @@ CodePress CMS is a lightweight, file-based content management system built with
- **Plugin management** - Overview, create, edit, configure, enable/disable and delete - **Plugin management** - Overview, create, edit, configure, enable/disable and delete
- **Media management** - Upload and delete media files in `content/-assets/` - **Media management** - Upload and delete media files in `content/-assets/`
- **User management** - Add, remove users, change passwords - **User management** - Add, remove users, change passwords
- **Guide** - Built-in documentation with API reference
- Session-based authentication with bcrypt hashing - Session-based authentication with bcrypt hashing
- CSRF protection, brute-force lockout (5 attempts, 15 min) - CSRF protection, brute-force lockout (5 attempts, 15 min)
- Default login: `admin` / `admin` (change immediately after installation) - Default login: `admin` / `admin` (change immediately after installation)
@@ -286,6 +287,7 @@ Media files (images, PDFs, video, audio) can be placed in any `content/` subdire
| `media` | Media management (upload, delete) | | `media` | Media management (upload, delete) |
| `media-list` | JSON list of all media (for editor modal) | | `media-list` | JSON list of all media (for editor modal) |
| `users` | User management | | `users` | User management |
| `guide` | Guide / documentation |
### Editor Features ### Editor Features
@@ -491,6 +493,102 @@ class MyPlugin
The MQTTTracker plugin stores `broker_host`, `broker_port`, `client_id`, `username` and `password` in plain text in `plugins/MQTTTracker/config.json`. This is a known open security issue - in a production environment it is recommended to externalize these credentials to environment variables or a separate credential manager. The MQTTTracker plugin stores `broker_host`, `broker_port`, `client_id`, `username` and `password` in plain text in `plugins/MQTTTracker/config.json`. This is a known open security issue - in a production environment it is recommended to externalize these credentials to environment variables or a separate credential manager.
## Content API (for PHP content files)
PHP content files (`.php` in the `content/` directory) have access to an `$api` variable with the following methods:
### Getting pages
```php
// Get all pages with titles
$pages = $api->getAllPages();
// Result: ['index' => 'Home', 'about' => 'About Us', ...]
// Get a specific page's content
$page = $api->getPage('about');
// $page['title'], $page['content'], $page['path'], $page['layout'], $page['metadata']
// Check if a page exists
if ($api->pageExists('contact')) {
// ...
}
```
### Navigation
```php
// Get menu structure
$menu = $api->getMenu();
// Nested array with 'title', 'path', 'url', 'children'
```
### Configuration
```php
// Get config value (dot notation)
$title = $api->getConfig('site_title');
$lang = $api->getConfig('language.default');
$seoDesc = $api->getConfig('seo.description', 'Default description');
```
### Current page
```php
// Current page title
$pageTitle = $api->getCurrentPageTitle();
// Current page path
$pagePath = $api->getCurrentPagePath();
// Check if this is the homepage
if ($api->isHomepage()) {
echo 'Welcome!';
}
```
### URLs and language
```php
// Build URL for a page
$url = $api->buildUrl('about', 'en');
// Current language
$lang = $api->getCurrentLanguage();
// Available languages
$languages = $api->getAvailableLanguages();
// Site title
$title = $api->getSiteTitle();
```
### Translations and search
```php
// Get translation
$label = $api->t('home');
// Search results (if searching)
if ($api->isSearching()) {
$results = $api->getSearchResults();
}
```
### Example PHP content file
```php
---
title: Page Overview
layout: content
---
<h1>All Pages</h1>
<ul>
<?php foreach ($api->getAllPages() as $path => $title): ?>
<li><a href="<?= $api->buildUrl($path) ?>"><?= htmlspecialchars($title) ?></a></li>
<?php endforeach; ?>
</ul>
```
## Analytics & Tracking ## Analytics & Tracking
### MQTT Tracker ### MQTT Tracker
+99 -1
View File
@@ -18,7 +18,7 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
### Content Types ### Content Types
- **Markdown (.md)** - CommonMark ondersteuning via `league/commonmark` - **Markdown (.md)** - CommonMark ondersteuning via `league/commonmark`
- **PHP (.php)** - Dynamische content - **PHP (.php)** - Dynamische content met **Content API** (`$api` variabele)
- **HTML (.html)** - Statische HTML pagina's - **HTML (.html)** - Statische HTML pagina's
- **Directory listings** - Automatische directory overzichten - **Directory listings** - Automatische directory overzichten
- **Language-specific content** - `nl.` en `en.` prefix - **Language-specific content** - `nl.` en `en.` prefix
@@ -63,6 +63,7 @@ CodePress CMS is een lichtgewicht, file-based content management systeem gebouwd
- **Plugin beheer** - Overzicht, aanmaken, bewerken, configureren, in-/uitschakelen en verwijderen - **Plugin beheer** - Overzicht, aanmaken, bewerken, configureren, in-/uitschakelen en verwijderen
- **Media beheer** - Uploaden en verwijderen van mediabestanden in `content/-assets/` - **Media beheer** - Uploaden en verwijderen van mediabestanden in `content/-assets/`
- **Gebruikersbeheer** - Gebruikers toevoegen, verwijderen, wachtwoorden wijzigen - **Gebruikersbeheer** - Gebruikers toevoegen, verwijderen, wachtwoorden wijzigen
- **Handleiding** - Ingebouwde documentatie met API referentie
- Session-based authenticatie met bcrypt hashing - Session-based authenticatie met bcrypt hashing
- CSRF-bescherming, brute-force lockout (5 pogingen, 15 min) - CSRF-bescherming, brute-force lockout (5 pogingen, 15 min)
- Standaard login: `admin` / `admin` (wijzig direct na installatie) - Standaard login: `admin` / `admin` (wijzig direct na installatie)
@@ -287,6 +288,7 @@ Media bestanden (afbeeldingen, PDFs, video, audio) kunnen in elke `content/` sub
| `media` | Media beheer (uploaden, verwijderen) | | `media` | Media beheer (uploaden, verwijderen) |
| `media-list` | JSON lijst van alle media (voor editor modal) | | `media-list` | JSON lijst van alle media (voor editor modal) |
| `users` | Gebruikersbeheer | | `users` | Gebruikersbeheer |
| `guide` | Handleiding (deze documentatie) |
### Editor Functionaliteit ### Editor Functionaliteit
@@ -492,6 +494,102 @@ class MijnPlugin
De MQTTTracker plugin slaat `broker_host`, `broker_port`, `client_id`, `username` en `password` op in plain text in `plugins/MQTTTracker/config.json`. Dit is een bekend openstaand security punt - bij een productieomgeving wordt aangeraden deze gegevens te externaliseren naar omgevingsvariabelen of een aparte credentials manager. De MQTTTracker plugin slaat `broker_host`, `broker_port`, `client_id`, `username` en `password` op in plain text in `plugins/MQTTTracker/config.json`. Dit is een bekend openstaand security punt - bij een productieomgeving wordt aangeraden deze gegevens te externaliseren naar omgevingsvariabelen of een aparte credentials manager.
## Content API (voor PHP content bestanden)
PHP content bestanden (`.php` in de `content/` map) hebben toegang tot een `$api` variabele met de volgende methodes:
### Pagina's opvragen
```php
// Alle pagina's met titels ophalen
$pages = $api->getAllPages();
// Resultaat: ['index' => 'Home', 'over-ons' => 'Over ons', ...]
// specifieke pagina inhoud ophalen
$page = $api->getPage('over-ons');
// $page['title'], $page['content'], $page['path'], $page['layout'], $page['metadata']
// Controleren of een pagina bestaat
if ($api->pageExists('contact')) {
// ...
}
```
### Navigatie
```php
// Menu structuur ophalen
$menu = $api->getMenu();
// Bevat geneste array met 'title', 'path', 'url', 'children'
```
### Configuratie
```php
// Configuratie waarde opvragen (punt-notatie)
$title = $api->getConfig('site_title');
$lang = $api->getConfig('language.default');
$seoDesc = $api->getConfig('seo.description', 'Standaard beschrijving');
```
### Huidige pagina
```php
// Huidige pagina titel
$pageTitle = $api->getCurrentPageTitle();
// Huidige pagina pad
$pagePath = $api->getCurrentPagePath();
// Check of dit de homepage is
if ($api->isHomepage()) {
echo 'Welkom!';
}
```
### URLs en taal
```php
// URL bouwen voor een pagina
$url = $api->buildUrl('over-ons', 'nl');
// Huidige taal
$lang = $api->getCurrentLanguage();
// Beschikbare talen
$languages = $api->getAvailableLanguages();
// Site titel
$title = $api->getSiteTitle();
```
### Vertalingen en zoeken
```php
// Vertaling ophalen
$label = $api->t('home');
// Zoekresultaten (als er gezocht wordt)
if ($api->isSearching()) {
$results = $api->getSearchResults();
}
```
### Voorbeeld PHP content bestand
```php
---
title: Pagina Overzicht
layout: content
---
<h1>Alle Pagina's</h1>
<ul>
<?php foreach ($api->getAllPages() as $path => $title): ?>
<li><a href="<?= $api->buildUrl($path) ?>"><?= htmlspecialchars($title) ?></a></li>
<?php endforeach; ?>
</ul>
```
## Analytics & Tracking ## Analytics & Tracking
### MQTT Tracker ### MQTT Tracker
+53 -12
View File
@@ -121,6 +121,10 @@ switch ($route) {
handleMediaList($auth, $appConfig); handleMediaList($auth, $appConfig);
break; break;
case 'guide':
handleGuide($auth, $appConfig);
break;
case 'users': case 'users':
handleUsers($auth, $appConfig); handleUsers($auth, $appConfig);
break; break;
@@ -662,24 +666,28 @@ function handleConfig(AdminAuth $auth, array $config): void
$configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : []; $configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
if (!is_array($configData)) $configData = []; if (!is_array($configData)) $configData = [];
// Get available root-level pages // Get available pages (including subdirectories)
$contentDir = $config['content_dir']; $contentDir = $config['content_dir'];
$availablePages = []; $availablePages = [];
$availableLangs = $configData['language']['available'] ?? ['nl', 'en']; $availableLangs = $configData['language']['available'] ?? ['nl', 'en'];
if (is_dir($contentDir)) { if (is_dir($contentDir)) {
$pageMap = []; $pageMap = [];
foreach (scandir($contentDir) as $file) { $realContentDir = realpath($contentDir);
if ($file[0] === '.') continue; $iterator = new RecursiveIteratorIterator(
$filePath = $contentDir . '/' . $file; new RecursiveDirectoryIterator($contentDir, RecursiveDirectoryIterator::SKIP_DOTS)
if (is_file($filePath) && preg_match('/\.(md|php|html)$/', $file)) { );
$base = $file; foreach ($iterator as $fileInfo) {
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/'; if (!$fileInfo->isFile()) continue;
if (preg_match($langPattern, $base, $m)) { $ext = $fileInfo->getExtension();
$base = $m[2]; if (!in_array($ext, ['md', 'php', 'html'])) continue;
} $relative = substr($fileInfo->getRealPath(), strlen($realContentDir) + 1);
$pageKey = preg_replace('/\.(md|php|html)$/', '', $base); $base = $relative;
$pageMap[$pageKey] = true; $langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
if (preg_match($langPattern, $base, $m)) {
$base = $m[2];
} }
$pageKey = preg_replace('/\.(md|php|html)$/', '', $base);
$pageMap[$pageKey] = true;
} }
$availablePages = array_keys($pageMap); $availablePages = array_keys($pageMap);
sort($availablePages); sort($availablePages);
@@ -1360,6 +1368,39 @@ function handleUsers(AdminAuth $auth, array $config): void
require __DIR__ . '/../admin/templates/layout.php'; require __DIR__ . '/../admin/templates/layout.php';
} }
function handleGuide(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
// Determine language for guide
$lang = $_GET['lang'] ?? 'nl';
if (!in_array($lang, ['nl', 'en'])) $lang = 'nl';
$guideFile = __DIR__ . '/../guide/' . $lang . '.codepress.md';
if (!file_exists($guideFile)) {
$guideFile = __DIR__ . '/../guide/en.codepress.md';
$lang = 'en';
}
$rawContent = file_get_contents($guideFile);
// Parse markdown using CommonMark if available
$content = '';
if (class_exists('League\CommonMark\CommonMarkConverter')) {
$converter = new League\CommonMark\CommonMarkConverter([
'html_input' => 'allow',
'allow_unsafe_links' => false,
]);
$content = $converter->convert($rawContent)->getContent();
} else {
$content = '<pre>' . htmlspecialchars($rawContent) . '</pre>';
}
$route = 'guide';
require __DIR__ . '/../admin/templates/layout.php';
}
// --- Frontmatter helpers --- // --- Frontmatter helpers ---
function parseFrontmatterField(string $content, string $key, string $default = ''): string function parseFrontmatterField(string $content, string $key, string $default = ''): string