Add multi-file page support and improve documentation
Update README to explain multi-file page functionality Add example content files demonstrating the feature Improve folder type detection logic Implement new routing for page-type folders Add support for mixed content types in single pages Update navigation and metadata handling for multi-file pages Remove legacy frontpage.php in favor of multi-file approach Improve file-based routing documentation Add examples of different content types working together Update router to handle multi-file content rendering Implement proper sorting of content files Add best practices for multi-file content organization
This commit is contained in:
parent
f2c18659dc
commit
b507a0c676
20 changed files with 458 additions and 240 deletions
277
app/router.php
277
app/router.php
|
|
@ -49,38 +49,74 @@ $customListTemplate = dirname(__DIR__) . '/custom/templates/list.php';
|
|||
$defaultListTemplate = __DIR__ . '/default/templates/list.php';
|
||||
$listTemplate = file_exists($customListTemplate) ? $customListTemplate : $defaultListTemplate;
|
||||
|
||||
// Build file patterns with language variants
|
||||
function buildFilePatterns(string $lang, string $defaultLang): array {
|
||||
$extensions = ['php', 'html', 'md'];
|
||||
$patterns = ['page' => [], 'single' => []];
|
||||
|
||||
foreach ($extensions as $ext) {
|
||||
// Language-specific files first (if not default language)
|
||||
if ($lang !== $defaultLang) {
|
||||
$patterns['page'][] = "page.$lang.$ext";
|
||||
$patterns['single'][] = "single.$lang.$ext";
|
||||
$patterns['single'][] = "post.$lang.$ext";
|
||||
$patterns['single'][] = "article.$lang.$ext";
|
||||
// Find all content files in a directory (supporting language variants)
|
||||
function findAllContentFiles(string $dir, string $lang, string $defaultLang): array {
|
||||
if (!is_dir($dir)) return [];
|
||||
|
||||
$files = scandir($dir) ?: [];
|
||||
$contentFiles = [];
|
||||
$extensions = ['md', 'html', 'php'];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') continue;
|
||||
|
||||
$ext = pathinfo($file, PATHINFO_EXTENSION);
|
||||
if (!in_array($ext, $extensions)) continue;
|
||||
|
||||
$filePath = "$dir/$file";
|
||||
if (!is_file($filePath)) continue;
|
||||
|
||||
// Parse filename to check for language variant
|
||||
$parts = explode('.', $file);
|
||||
|
||||
// Check if this is a language-specific file
|
||||
if (count($parts) >= 3) {
|
||||
// Pattern: name.lang.ext
|
||||
$fileLang = $parts[count($parts) - 2];
|
||||
if (in_array($fileLang, ['no', 'en'])) {
|
||||
// Only include if it matches current language
|
||||
if ($fileLang === $lang) {
|
||||
$contentFiles[] = [
|
||||
'path' => $filePath,
|
||||
'name' => $file,
|
||||
'sort_key' => $parts[0] // Use base name for sorting
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Default files (no language suffix) - include if current lang is default
|
||||
// or if no language-specific version exists
|
||||
$baseName = $parts[0];
|
||||
$hasLangVersion = false;
|
||||
|
||||
if ($lang !== $defaultLang) {
|
||||
// Check if language-specific version exists
|
||||
foreach ($extensions as $checkExt) {
|
||||
if (file_exists("$dir/$baseName.$lang.$checkExt")) {
|
||||
$hasLangVersion = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLangVersion) {
|
||||
$contentFiles[] = [
|
||||
'path' => $filePath,
|
||||
'name' => $file,
|
||||
'sort_key' => $baseName
|
||||
];
|
||||
}
|
||||
|
||||
// Default files
|
||||
$patterns['page'][] = "page.$ext";
|
||||
$patterns['single'][] = "single.$ext";
|
||||
$patterns['single'][] = "post.$ext";
|
||||
$patterns['single'][] = "article.$ext";
|
||||
}
|
||||
|
||||
return $patterns;
|
||||
|
||||
// Sort by filename (alphanumerical)
|
||||
usort($contentFiles, fn($a, $b) => strnatcmp($a['sort_key'], $b['sort_key']));
|
||||
|
||||
return array_column($contentFiles, 'path');
|
||||
}
|
||||
|
||||
$pageFilePatterns = buildFilePatterns($currentLang, $defaultLang);
|
||||
|
||||
function findMatchingFile(string $dir, array $patterns): ?string {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (file_exists($file = "$dir/$pattern")) return $file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTranslatedPath(string $requestPath, string $contentDir, string $lang, string $defaultLang): string {
|
||||
// If default language, no translation needed
|
||||
|
|
@ -124,7 +160,7 @@ function resolveTranslatedPath(string $requestPath, string $contentDir, string $
|
|||
return implode('/', $resolvedParts);
|
||||
}
|
||||
|
||||
function parseRequestPath(string $requestPath, string $contentDir, array $patterns, bool $hasTrailingSlash, string $lang, string $defaultLang): array {
|
||||
function parseRequestPath(string $requestPath, string $contentDir, bool $hasTrailingSlash, string $lang, string $defaultLang): array {
|
||||
// Resolve translated slugs to actual directory names
|
||||
$resolvedPath = resolveTranslatedPath($requestPath, $contentDir, $lang, $defaultLang);
|
||||
$contentPath = rtrim($contentDir, '/') . '/' . ltrim($resolvedPath, '/');
|
||||
|
|
@ -140,11 +176,20 @@ function parseRequestPath(string $requestPath, string $contentDir, array $patter
|
|||
fn($item) => !in_array($item, ['.', '..']) && is_dir("$contentPath/$item")
|
||||
));
|
||||
|
||||
// If directory has subdirectories, treat as directory (for list views)
|
||||
// Otherwise, if it has a content file, treat as file
|
||||
if (!$hasSubdirs && ($file = findMatchingFile($contentPath, $patterns['single']) ?: findMatchingFile($contentPath, $patterns['page']))) {
|
||||
return ['type' => 'file', 'path' => $file, 'needsSlash' => !$hasTrailingSlash];
|
||||
// If directory has subdirectories, it's an article-type folder (list view)
|
||||
if ($hasSubdirs) {
|
||||
return ['type' => 'directory', 'path' => realpath($contentPath)];
|
||||
}
|
||||
|
||||
// No subdirectories - it's a page-type folder
|
||||
// Find all content files in this directory
|
||||
$contentFiles = findAllContentFiles($contentPath, $lang, $defaultLang);
|
||||
|
||||
if (!empty($contentFiles)) {
|
||||
return ['type' => 'page', 'path' => realpath($contentPath), 'files' => $contentFiles, 'needsSlash' => !$hasTrailingSlash];
|
||||
}
|
||||
|
||||
// No content files found
|
||||
return ['type' => 'directory', 'path' => realpath($contentPath)];
|
||||
}
|
||||
|
||||
|
|
@ -169,10 +214,12 @@ function loadMetadata(string $dirPath, string $lang, string $defaultLang): ?arra
|
|||
return $baseMetadata ?: null;
|
||||
}
|
||||
|
||||
function extractTitle(string $filePath, array $patterns): ?string {
|
||||
$file = findMatchingFile($filePath, $patterns['single']) ?: findMatchingFile($filePath, $patterns['page']);
|
||||
if (!$file) return null;
|
||||
function extractTitle(string $filePath, string $lang, string $defaultLang): ?string {
|
||||
$files = findAllContentFiles($filePath, $lang, $defaultLang);
|
||||
if (empty($files)) return null;
|
||||
|
||||
// Check the first content file for a title
|
||||
$file = $files[0];
|
||||
$ext = pathinfo($file, PATHINFO_EXTENSION);
|
||||
$content = file_get_contents($file);
|
||||
|
||||
|
|
@ -232,7 +279,7 @@ function loadTranslations(string $lang): array {
|
|||
return [];
|
||||
}
|
||||
|
||||
function buildNavigation(string $contentDir, string $currentLang, string $defaultLang, array $pageFilePatterns): array {
|
||||
function buildNavigation(string $contentDir, string $currentLang, string $defaultLang): array {
|
||||
$navItems = [];
|
||||
|
||||
// Scan top-level directories in content
|
||||
|
|
@ -252,30 +299,16 @@ function buildNavigation(string $contentDir, string $currentLang, string $defaul
|
|||
|
||||
// Check if content exists for current language
|
||||
if ($currentLang !== $defaultLang) {
|
||||
$extensions = ['php', 'html', 'md'];
|
||||
$hasContent = false;
|
||||
|
||||
// Check for language-specific content files
|
||||
foreach ($extensions as $ext) {
|
||||
if (file_exists("$itemPath/single.$currentLang.$ext") ||
|
||||
file_exists("$itemPath/post.$currentLang.$ext") ||
|
||||
file_exists("$itemPath/article.$currentLang.$ext") ||
|
||||
file_exists("$itemPath/page.$currentLang.$ext")) {
|
||||
$hasContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no language-specific files, check if metadata has title for this language
|
||||
if (!$hasContent && $metadata && isset($metadata['title'])) {
|
||||
$hasContent = true;
|
||||
}
|
||||
$contentFiles = findAllContentFiles($itemPath, $currentLang, $defaultLang);
|
||||
|
||||
// If no content files, check if metadata has title for this language
|
||||
$hasContent = !empty($contentFiles) || ($metadata && isset($metadata['title']));
|
||||
|
||||
if (!$hasContent) continue;
|
||||
}
|
||||
|
||||
// Extract title and build URL
|
||||
$title = $metadata['title'] ?? extractTitle($itemPath, $pageFilePatterns) ?? ucfirst($item);
|
||||
$title = $metadata['title'] ?? extractTitle($itemPath, $currentLang, $defaultLang) ?? ucfirst($item);
|
||||
$langPrefix = $currentLang !== $defaultLang ? "/$currentLang" : '';
|
||||
|
||||
// Use translated slug if available
|
||||
|
|
@ -297,10 +330,10 @@ function buildNavigation(string $contentDir, string $currentLang, string $defaul
|
|||
}
|
||||
|
||||
function renderTemplate(string $content, int $statusCode = 200): void {
|
||||
global $baseTemplate, $contentDir, $currentLang, $defaultLang, $pageFilePatterns;
|
||||
global $baseTemplate, $contentDir, $currentLang, $defaultLang;
|
||||
|
||||
// Build navigation for templates
|
||||
$navigation = buildNavigation($contentDir, $currentLang, $defaultLang, $pageFilePatterns);
|
||||
$navigation = buildNavigation($contentDir, $currentLang, $defaultLang);
|
||||
|
||||
// Load frontpage metadata for home button label
|
||||
$frontpageMetadata = loadMetadata($contentDir, $currentLang, $defaultLang);
|
||||
|
|
@ -315,7 +348,7 @@ function renderTemplate(string $content, int $statusCode = 200): void {
|
|||
}
|
||||
|
||||
function renderFile(string $filePath): void {
|
||||
global $baseTemplate, $pageTemplate, $contentDir, $currentLang, $defaultLang, $pageFilePatterns;
|
||||
global $baseTemplate, $pageTemplate, $contentDir, $currentLang, $defaultLang;
|
||||
|
||||
$realPath = realpath($filePath);
|
||||
if (!$realPath || !str_starts_with($realPath, $contentDir) || !is_readable($realPath)) {
|
||||
|
|
@ -337,7 +370,7 @@ function renderFile(string $filePath): void {
|
|||
$content = ob_get_clean();
|
||||
|
||||
// Build navigation for templates
|
||||
$navigation = buildNavigation($contentDir, $currentLang, $defaultLang, $pageFilePatterns);
|
||||
$navigation = buildNavigation($contentDir, $currentLang, $defaultLang);
|
||||
|
||||
// Load metadata for current page/directory
|
||||
$pageDir = dirname($realPath);
|
||||
|
|
@ -367,6 +400,60 @@ function renderFile(string $filePath): void {
|
|||
exit;
|
||||
}
|
||||
|
||||
function renderMultipleFiles(array $filePaths, string $pageDir): void {
|
||||
global $baseTemplate, $pageTemplate, $contentDir, $currentLang, $defaultLang;
|
||||
|
||||
// Validate all files are safe
|
||||
foreach ($filePaths as $filePath) {
|
||||
$realPath = realpath($filePath);
|
||||
if (!$realPath || !str_starts_with($realPath, $contentDir) || !is_readable($realPath)) {
|
||||
renderTemplate("<article><h1>403 Forbidden</h1><p>Access denied.</p></article>", 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Render all content files in order
|
||||
$content = '';
|
||||
foreach ($filePaths as $filePath) {
|
||||
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
|
||||
|
||||
ob_start();
|
||||
if ($ext === 'md') {
|
||||
if (!class_exists('Parsedown')) {
|
||||
require_once __DIR__ . '/vendor/Parsedown.php';
|
||||
}
|
||||
echo '<article>' . (new Parsedown())->text(file_get_contents($filePath)) . '</article>';
|
||||
} elseif ($ext === 'html') {
|
||||
include $filePath;
|
||||
} elseif ($ext === 'php') {
|
||||
include $filePath;
|
||||
}
|
||||
$content .= ob_get_clean();
|
||||
}
|
||||
|
||||
// Build navigation for templates
|
||||
$navigation = buildNavigation($contentDir, $currentLang, $defaultLang);
|
||||
|
||||
// Load metadata for current page/directory
|
||||
$pageMetadata = loadMetadata($pageDir, $currentLang, $defaultLang);
|
||||
$pageTitle = $pageMetadata['title'] ?? null;
|
||||
|
||||
// Load frontpage metadata for home button label
|
||||
$frontpageMetadata = loadMetadata($contentDir, $currentLang, $defaultLang);
|
||||
$homeLabel = $frontpageMetadata['slug'] ?? 'Home';
|
||||
|
||||
// Load translations
|
||||
$translations = loadTranslations($currentLang);
|
||||
|
||||
// Wrap content with page template
|
||||
ob_start();
|
||||
include $pageTemplate;
|
||||
$content = ob_get_clean();
|
||||
|
||||
// Wrap with base template
|
||||
include $baseTemplate;
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check for assets in /custom/assets/ served at root level
|
||||
$assetPath = dirname(__DIR__) . '/custom/assets/' . $requestPath;
|
||||
if (file_exists($assetPath) && is_file($assetPath)) {
|
||||
|
|
@ -377,24 +464,29 @@ if (file_exists($assetPath) && is_file($assetPath)) {
|
|||
|
||||
// Handle frontpage
|
||||
if (empty($requestPath)) {
|
||||
// Try language-specific frontpage first, then default
|
||||
$frontPage = null;
|
||||
if ($currentLang !== $defaultLang && file_exists("$contentDir/frontpage.$currentLang.php")) {
|
||||
$frontPage = "$contentDir/frontpage.$currentLang.php";
|
||||
} elseif (file_exists("$contentDir/frontpage.php")) {
|
||||
$frontPage = "$contentDir/frontpage.php";
|
||||
}
|
||||
|
||||
if ($frontPage) {
|
||||
renderFile($frontPage);
|
||||
// Find all content files in the root content directory
|
||||
$contentFiles = findAllContentFiles($contentDir, $currentLang, $defaultLang);
|
||||
|
||||
if (!empty($contentFiles)) {
|
||||
renderMultipleFiles($contentFiles, $contentDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and handle request
|
||||
$parsedPath = parseRequestPath($requestPath, $contentDir, $pageFilePatterns, $hasTrailingSlash, $currentLang, $defaultLang);
|
||||
$parsedPath = parseRequestPath($requestPath, $contentDir, $hasTrailingSlash, $currentLang, $defaultLang);
|
||||
|
||||
switch ($parsedPath['type']) {
|
||||
case 'page':
|
||||
// Page-type folder with content files (no subdirectories)
|
||||
// Redirect to add trailing slash if needed
|
||||
if (!empty($parsedPath['needsSlash'])) {
|
||||
header('Location: ' . rtrim($_SERVER['REQUEST_URI'], '/') . '/', true, 301);
|
||||
exit;
|
||||
}
|
||||
renderMultipleFiles($parsedPath['files'], $parsedPath['path']);
|
||||
|
||||
case 'file':
|
||||
// Direct file access or legacy single file
|
||||
// Redirect to add trailing slash if this is a directory-based page
|
||||
if (!empty($parsedPath['needsSlash'])) {
|
||||
header('Location: ' . rtrim($_SERVER['REQUEST_URI'], '/') . '/', true, 301);
|
||||
|
|
@ -408,18 +500,21 @@ switch ($parsedPath['type']) {
|
|||
renderFile("$dir/index.php");
|
||||
}
|
||||
|
||||
// Check for page content file in this directory
|
||||
// Check for page content files in this directory
|
||||
$pageContent = null;
|
||||
if ($pageFile = findMatchingFile($dir, $pageFilePatterns['page'])) {
|
||||
$ext = pathinfo($pageFile, PATHINFO_EXTENSION);
|
||||
$contentFiles = findAllContentFiles($dir, $currentLang, $defaultLang);
|
||||
if (!empty($contentFiles)) {
|
||||
ob_start();
|
||||
if ($ext === 'md') {
|
||||
if (!class_exists('Parsedown')) {
|
||||
require_once __DIR__ . '/vendor/Parsedown.php';
|
||||
foreach ($contentFiles as $file) {
|
||||
$ext = pathinfo($file, PATHINFO_EXTENSION);
|
||||
if ($ext === 'md') {
|
||||
if (!class_exists('Parsedown')) {
|
||||
require_once __DIR__ . '/vendor/Parsedown.php';
|
||||
}
|
||||
echo (new Parsedown())->text(file_get_contents($file));
|
||||
} else {
|
||||
include $file;
|
||||
}
|
||||
echo (new Parsedown())->text(file_get_contents($pageFile));
|
||||
} else {
|
||||
include $pageFile;
|
||||
}
|
||||
$pageContent = ob_get_clean();
|
||||
}
|
||||
|
|
@ -451,34 +546,20 @@ switch ($parsedPath['type']) {
|
|||
fn($item) => !in_array($item, ['.', '..']) && is_dir("$dir/$item")
|
||||
);
|
||||
|
||||
$items = array_filter(array_map(function($item) use ($dir, $requestPath, $currentLang, $defaultLang, $pageFilePatterns) {
|
||||
$items = array_filter(array_map(function($item) use ($dir, $requestPath, $currentLang, $defaultLang) {
|
||||
$itemPath = "$dir/$item";
|
||||
|
||||
// Check if content exists for current language
|
||||
if ($currentLang !== $defaultLang) {
|
||||
// For non-default languages, only check language-specific files
|
||||
$extensions = ['php', 'html', 'md'];
|
||||
$hasContent = false;
|
||||
foreach ($extensions as $ext) {
|
||||
if (file_exists("$itemPath/single.$currentLang.$ext") ||
|
||||
file_exists("$itemPath/post.$currentLang.$ext") ||
|
||||
file_exists("$itemPath/article.$currentLang.$ext") ||
|
||||
file_exists("$itemPath/page.$currentLang.$ext")) {
|
||||
$hasContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasContent) return null;
|
||||
$contentFiles = findAllContentFiles($itemPath, $currentLang, $defaultLang);
|
||||
if (empty($contentFiles)) return null;
|
||||
}
|
||||
|
||||
// Build patterns for rendering (includes fallbacks)
|
||||
$patterns = buildFilePatterns($currentLang, $defaultLang);
|
||||
|
||||
$metadata = loadMetadata($itemPath, $currentLang, $defaultLang);
|
||||
$coverImage = findCoverImage($itemPath);
|
||||
$pdfFile = findPdfFile($itemPath);
|
||||
|
||||
$title = $metadata['title'] ?? extractTitle($itemPath, $patterns) ?? $item;
|
||||
$title = $metadata['title'] ?? extractTitle($itemPath, $currentLang, $defaultLang) ?? $item;
|
||||
$date = null;
|
||||
if (isset($metadata['date'])) {
|
||||
$date = formatNorwegianDate($metadata['date']);
|
||||
|
|
@ -509,7 +590,7 @@ switch ($parsedPath['type']) {
|
|||
$content = ob_get_clean();
|
||||
|
||||
// Build navigation for base template
|
||||
$navigation = buildNavigation($contentDir, $currentLang, $defaultLang, $pageFilePatterns);
|
||||
$navigation = buildNavigation($contentDir, $currentLang, $defaultLang);
|
||||
|
||||
include $baseTemplate;
|
||||
exit;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue