v2.6.3 (Lyra): Content multi-type handling, getAllPages() structuur, . verberg-prefix

- Content bestanden met dezelfde naam maar ander type (md/php/html) worden
  correct geserveerd: URL met extensie opent dat bestand, URL zonder extensie
  valt terug op md > php > html (resolveContentByType helper)
- Admin content editor accepteert bestanden met dezelfde naam (ander type);
  preview-knop linkt per extensie
- Frontend navigatie/directory listing/search tonen elk bestandstype apart
- getAllPages() array structuur gewijzigd naar list van
  ['path','title','type'] met type 'md'/'php'/'html'/'folder'
- Verberg-prefix logica: _ is geen verberg-prefix meer, alleen . (en -);
  admin toont wél alle . bestanden/mappen
- ContentAPI getPage()/pageExists() respecteren expliciete extensie
- Handleiding content-api.md (NL+EN) herschreven
- File-tree unificatie: _file-tree.twig + _editor-styles.twig includes
- Versie verhoogd naar 2.6.3
This commit is contained in:
2026-08-20 16:45:53 +00:00
parent 6f18a13f90
commit 06beef43d7
28 changed files with 1481 additions and 950 deletions
+114 -61
View File
@@ -90,8 +90,9 @@ class CodePressCMS {
* Removes any characters that are not alphanumeric, dashes, underscores, or slashes
*/
private function sanitizePageParam(string $page): string {
// Remove any characters that could be used for XSS
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $page);
// Remove any characters that could be used for XSS; keep the dot so
// content file extensions (md/php/html) survive in the URL.
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/.]/', '', $page);
return $sanitized ?: 'invalid-page';
}
@@ -330,8 +331,8 @@ class CodePressCMS {
$result = [];
foreach ($items as $item) {
// Skip hidden/system entries: dotfiles, -assets, _drafts, etc.
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
// Skip assets directory (old name, kept for safety)
if ($item === 'assets' && is_dir($dir . '/' . $item)) continue;
@@ -359,12 +360,13 @@ class CodePressCMS {
// Always use filename for navigation (not H1 titles from content)
$filename = pathinfo($item, PATHINFO_FILENAME);
$title = $this->formatDisplayName($filename);
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
// Keep the extension in the URL so files sharing the same name
// but different type each keep their own viewable link.
$result[] = [
'type' => 'file',
'title' => $title,
'path' => $pathWithoutExt,
'url' => $this->buildUrl($pathWithoutExt)
'path' => $relativePath,
'url' => $this->buildUrl($relativePath)
];
}
}
@@ -398,8 +400,8 @@ class CodePressCMS {
$items = scandir($dir);
foreach ($items as $item) {
// Skip hidden/system entries (consistent met scanDirectory)
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
@@ -481,8 +483,13 @@ class CodePressCMS {
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
// Limit length
$page = substr($page, 0, 255);
// Only remove file extension at the end, not all dots
$pageWithoutExt = preg_replace('/\.(md|php|html)$/', '', $page);
// Detect explicit extension from the URL (e.g. /nl/test.php → use test.php)
$explicitExt = null;
if (preg_match('/\.(md|php|html)$/i', $page, $extMatch)) {
$explicitExt = strtolower($extMatch[1]);
}
// Strip any content extension from the URL for the base path
$pageWithoutExt = preg_replace('/\.(md|php|html)$/i', '', $page);
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
@@ -500,43 +507,32 @@ class CodePressCMS {
$actualFilePath = null;
// Check for exact file matches if no directory found
if (file_exists($filePath . '.md')) {
$actualFilePath = $filePath . '.md';
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
} elseif (file_exists($filePath . '.php')) {
$actualFilePath = $filePath . '.php';
$result = $this->parsePHP($actualFilePath);
} elseif (file_exists($filePath . '.html')) {
$actualFilePath = $filePath . '.html';
$result = $this->parseHTML(file_get_contents($actualFilePath));
} elseif (file_exists($filePath)) {
$actualFilePath = $filePath;
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
if ($extension === 'md') {
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
} elseif ($extension === 'php') {
$result = $this->parsePHP($actualFilePath);
} elseif ($extension === 'html') {
$result = $this->parseHTML(file_get_contents($actualFilePath));
// If the URL has an explicit extension, try that exact file first
if ($explicitExt !== null && file_exists($filePath . '.' . $explicitExt)) {
$actualFilePath = $filePath . '.' . $explicitExt;
$result = $this->resolveContentByType($filePath, $explicitExt);
if ($result !== null) {
$actualFilePath = $result['path'];
$result = $result['content'];
}
}
// No explicit extension: serve the first matching file (md > php > html)
if (!isset($result)) {
$resolved = $this->resolveContentByType($filePath);
if ($resolved !== null) {
$actualFilePath = $resolved['path'];
$result = $resolved['content'];
}
}
// If no exact match found, check for language-specific versions
if (!isset($result)) {
$result = null; // Reset result before language-specific search
$langPrefix = $this->currentLanguage;
if (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md')) {
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md';
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php')) {
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php';
$result = $this->parsePHP($actualFilePath);
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html')) {
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html';
$result = $this->parseHTML(file_get_contents($actualFilePath));
$langBase = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt;
$resolved = $this->resolveContentByType($langBase, $explicitExt);
if ($resolved !== null) {
$actualFilePath = $resolved['path'];
$result = $resolved['content'];
}
}
@@ -553,6 +549,40 @@ class CodePressCMS {
return $this->getError404();
}
/**
* Resolve the first content file matching a base path by type priority.
*
* A request without explicit extension (e.g. "test") serves the first
* existing file among test.md → test.php → test.html. When $preferredExt
* is given (the URL carried an extension), that type is tried first so
* /nl/test.php keeps serving test.php even when test.md also exists.
*
* @param string $basePath Absolute path without extension
* @param string $preferredExt Optional extension to try first (md|php|html)
* @return array|null ['path' => string, 'content' => array] or null
*/
private function resolveContentByType(string $basePath, ?string $preferredExt = null): ?array
{
$order = ['md', 'php', 'html'];
if ($preferredExt !== null && in_array($preferredExt, $order, true)) {
$order = array_unique(array_merge([$preferredExt], $order));
}
foreach ($order as $ext) {
$candidate = $basePath . '.' . $ext;
if (file_exists($candidate)) {
if ($ext === 'md') {
$content = $this->parseMarkdown(file_get_contents($candidate), $candidate);
} elseif ($ext === 'php') {
$content = $this->parsePHP($candidate);
} else {
$content = $this->parseHTML(file_get_contents($candidate));
}
return ['path' => $candidate, 'content' => $content];
}
}
return null;
}
/**
* Get file information including creation and modification dates
*
@@ -836,7 +866,11 @@ class CodePressCMS {
private function autoLinkPageTitles($content, $excludeTitle = '') {
$pages = $this->getAllPageTitles();
foreach ($pages as $pagePath => $pageTitle) {
foreach ($pages as $page) {
$pageTitle = $page['title'];
$pagePath = $page['path'];
// Alleen bestanden auto-linken, geen mappen
if ($page['type'] === 'folder') continue;
if (strtolower($pageTitle) === strtolower($excludeTitle)) {
continue;
}
@@ -863,9 +897,10 @@ class CodePressCMS {
}
/**
* Get all page titles from content directory
* Get all content entries (mappen + bestanden) from the content directory.
*
* @return array Associative array of page paths to titles
* @return array List of entries: ['path' => ..., 'title' => ..., 'type' => ...]
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
*/
public function getAllPageTitles() {
$pages = [];
@@ -874,7 +909,13 @@ class CodePressCMS {
}
/**
* Recursively scan for page titles in directory
* Recursively scan for content entries in a directory.
*
* Returns a flat list of ['path' => relativePath, 'title' => ..., 'type' => ...].
* Mappen krijgen type 'folder'; bestanden krijgen hun extensie als type.
* Bestanden met dezelfde naam maar ander type blijven apart (de extensie
* zit in het pad), en mappen krijgen hun eigen entry zodat de boom zichtbaar
* blijft.
*
* @param string $dir Directory to scan
* @param string $prefix Relative path prefix
@@ -888,26 +929,30 @@ class CodePressCMS {
sort($items);
foreach ($items as $item) {
// Skip hidden/system entries (consistent met scanDirectory)
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) {
$pages[] = [
'path' => $relativePath,
'title' => $this->formatDisplayName($item),
'type' => 'folder',
];
$this->scanForPageTitles($path, $relativePath, $pages);
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
$title = $this->extractPageTitle($path);
if ($title && !empty(trim($title))) {
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $title;
} else {
// Fallback to clean filename if no title found in content
if (!$title || empty(trim($title))) {
$filename = basename($path, pathinfo($path, PATHINFO_EXTENSION));
$cleanName = $this->formatDisplayName($filename);
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $cleanName;
$title = $this->formatDisplayName($filename);
}
$pages[] = [
'path' => $relativePath,
'title' => $title,
'type' => pathinfo($item, PATHINFO_EXTENSION),
];
}
}
}
@@ -1002,9 +1047,15 @@ class CodePressCMS {
// Make API and metadata available to the included file
$api = new ContentAPI($this);
$pageMetadata = $metadata;
include $filePath;
$returnedContent = include $filePath;
$content = ob_get_clean();
// If the PHP file returned a string, use that as content (callback style).
// Otherwise use the buffered echo output.
if (is_string($returnedContent) && $returnedContent !== '') {
$content = $returnedContent;
}
// Remove any remaining metadata from PHP output
$content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content);
@@ -1232,7 +1283,8 @@ class CodePressCMS {
$allItems = [];
foreach ($items as $item) {
if ($item[0] === '.') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$itemPath = $dirPath . '/' . $item;
$relativePath = $pagePath ? $pagePath . '/' . $item : $item;
@@ -1246,15 +1298,16 @@ class CodePressCMS {
'type' => 'directory'
];
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
// Each file keeps its own link (incl. extension) so every type
// stays viewable at the frontend even when names overlap.
$extractedTitle = $this->extractPageTitle($itemPath);
$fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME));
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
$icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' :
(pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark');
$allItems[] = [
'name' => $fileTitle,
'path' => $pathWithoutExt,
'url' => $this->buildUrl($pathWithoutExt),
'path' => $relativePath,
'url' => $this->buildUrl($relativePath),
'icon' => $icon,
'type' => 'file'
];