404 - Not Found
The requested file could not be found.
diff --git a/app/config.php b/app/config.php index fba4263..337d4ce 100644 --- a/app/config.php +++ b/app/config.php @@ -10,9 +10,6 @@ function createContext(): Context { // Load global plugins getPluginManager()->loadGlobalPlugins($config); - $defaultLang = $config['languages']['default'] ?? 'no'; - $availableLangs = array_map('trim', explode(',', $config['languages']['available'] ?? 'no')); - // Use user content if exists and has content, otherwise fall back to demo content $userContentDir = $_SERVER['DOCUMENT_ROOT']; $demoContentDir = __DIR__ . '/default/content'; @@ -27,15 +24,6 @@ function createContext(): Context { $hasTrailingSlash = str_ends_with($requestUri, '/') && $requestUri !== '/'; $requestPath = trim($requestUri, '/'); - // Extract language from URL - $currentLang = $defaultLang; - $pathParts = explode('/', $requestPath); - if (!empty($pathParts[0]) && in_array($pathParts[0], $availableLangs) && $pathParts[0] !== $defaultLang) { - $currentLang = $pathParts[0]; - array_shift($pathParts); - $requestPath = implode('/', $pathParts); - } - // Resolve templates with custom fallback to defaults $templates = new Templates( base: resolveTemplate('base'), @@ -43,13 +31,19 @@ function createContext(): Context { list: resolveTemplate('list') ); - return new Context( + // Create base context + $ctx = new Context( contentDir: $contentDir, - currentLang: $currentLang, - defaultLang: $defaultLang, - availableLangs: $availableLangs, templates: $templates, requestPath: $requestPath, hasTrailingSlash: $hasTrailingSlash ); + + // Store globally for plugins + $GLOBALS['ctx'] = $ctx; + + // Let plugins modify context (e.g., extract language from URL) + $ctx = Hooks::apply(Hook::CONTEXT_READY, $ctx, $config); + + return $ctx; } diff --git a/app/content.php b/app/content.php index e902a0c..8c8782c 100644 --- a/app/content.php +++ b/app/content.php @@ -1,7 +1,7 @@ = 3) { - // Pattern: name.lang.ext - $fileLang = $parts[count($parts) - 2]; - if (in_array($fileLang, $availableLangs)) { - // Only include if it matches current language - if ($fileLang === $lang) { - $contentFiles[] = [ - 'path' => $filePath, - 'name' => $file, - 'sort_key' => $parts[0] - ]; - } - continue; - } - } - - // Default files (no language suffix) - include if no language-specific version exists - $baseName = $parts[0]; - $hasLangVersion = false; - - if ($lang !== $defaultLang) { - // Check if language-specific version exists - foreach (CONTENT_EXTENSIONS as $checkExt) { - if (file_exists("$dir/$baseName.$lang.$checkExt")) { - $hasLangVersion = true; - break; - } - } - } - - if (!$hasLangVersion) { - $contentFiles[] = [ - 'path' => $filePath, - 'name' => $file, - 'sort_key' => $baseName - ]; - } + $contentFiles[] = [ + 'path' => $filePath, + 'name' => $file, + 'ext' => $ext + ]; } - // Sort by filename (alphanumerical) - usort($contentFiles, fn($a, $b) => strnatcmp($a['sort_key'], $b['sort_key'])); + // Let plugins filter content files (e.g., by language) + $contentFiles = Hooks::apply(Hook::PROCESS_CONTENT, $contentFiles, $dir); + + // Sort by filename + usort($contentFiles, fn($a, $b) => strnatcmp($a['name'], $b['name'])); return array_column($contentFiles, 'path'); } -function resolveTranslatedPath(Context $ctx, string $requestPath): string { - // If default language, no translation needed - if ($ctx->currentLang === $ctx->defaultLang) { - return $requestPath; - } - - $parts = explode('/', trim($requestPath, '/')); - $resolvedParts = []; - $currentPath = $ctx->contentDir; - - foreach ($parts as $segment) { - if (empty($segment)) continue; - - // Check all subdirectories for slug matches - $found = false; - if (is_dir($currentPath)) { - $subdirs = getSubdirectories($currentPath); - - foreach ($subdirs as $dir) { - $metadata = loadMetadata("$currentPath/$dir", $ctx->currentLang, $ctx->defaultLang); - if ($metadata && isset($metadata['slug']) && $metadata['slug'] === $segment) { - $resolvedParts[] = $dir; - $currentPath .= "/$dir"; - $found = true; - break; - } - } - } - - // If no slug match, use segment as-is - if (!$found) { - $resolvedParts[] = $segment; - $currentPath .= "/$segment"; - } - } - - return implode('/', $resolvedParts); -} - function parseRequestPath(Context $ctx): array { - // Resolve translated slugs to actual directory names - $resolvedPath = resolveTranslatedPath($ctx, $ctx->requestPath); - $contentPath = rtrim($ctx->contentDir, '/') . '/' . ltrim($resolvedPath, '/'); - - if (is_file($contentPath)) { - return ['type' => 'file', 'path' => realpath($contentPath)]; + $requestPath = $ctx->requestPath; + + if (empty($requestPath)) { + return ['type' => 'frontpage', 'path' => $ctx->contentDir]; } - + + $contentPath = $ctx->contentDir . '/' . $requestPath; + + // Check if it's a directory if (is_dir($contentPath)) { - // Check if directory has subdirectories (PHP 8.4: cleaner with array_any later) - $hasSubdirs = !empty(getSubdirectories($contentPath)); - - // If directory has subdirectories, it's an article-type folder (list view) - if ($hasSubdirs) { - return ['type' => 'directory', 'path' => realpath($contentPath)]; + $items = scandir($contentPath) ?: []; + $subdirs = array_filter($items, fn($item) => + $item !== '.' && $item !== '..' && is_dir("$contentPath/$item") + ); + + if (!empty($subdirs)) { + return ['type' => 'list', 'path' => $contentPath]; + } else { + return ['type' => 'page', 'path' => $contentPath]; } - - // No subdirectories - it's a page-type folder - // Find all content files in this directory - $contentFiles = findAllContentFiles($contentPath, $ctx->currentLang, $ctx->defaultLang, $ctx->availableLangs); - - if (!empty($contentFiles)) { - return ['type' => 'page', 'path' => realpath($contentPath), 'files' => $contentFiles, 'needsSlash' => !$ctx->hasTrailingSlash]; - } - - // No content files found - return ['type' => 'directory', 'path' => realpath($contentPath)]; } - + return ['type' => 'not_found', 'path' => $contentPath]; } -function loadMetadata(string $dirPath, string $lang, string $defaultLang): ?array { +function loadMetadata(string $dirPath): ?array { $metadataFile = "$dirPath/metadata.ini"; if (!file_exists($metadataFile)) return null; - + $metadata = parse_ini_file($metadataFile, true); if (!$metadata) return null; - - // Extract base metadata (non-section values) + + // Get base metadata (non-array values) $baseMetadata = array_filter($metadata, fn($key) => !is_array($metadata[$key]), ARRAY_FILTER_USE_KEY); - - // If current language is not default, merge language-specific overrides - if ($lang !== $defaultLang && isset($metadata[$lang]) && is_array($metadata[$lang])) { - $baseMetadata = array_merge($baseMetadata, $metadata[$lang]); - } - - return $baseMetadata ?: null; + + // Store full metadata for plugins to access + $baseMetadata['_raw'] = $metadata; + + // Let plugins modify metadata (e.g., merge language sections) + return Hooks::apply(Hook::PROCESS_CONTENT, $baseMetadata, $dirPath, 'metadata'); } - - function buildNavigation(Context $ctx): array { + $items = scandir($ctx->contentDir) ?: []; $navItems = []; - $items = getSubdirectories($ctx->contentDir); - + foreach ($items as $item) { + if ($item === '.' || $item === '..' || !is_dir($ctx->contentDir . "/$item")) continue; + $itemPath = "{$ctx->contentDir}/$item"; - $metadata = loadMetadata($itemPath, $ctx->currentLang, $ctx->defaultLang); - - // Check if this item should be in menu - if (!$metadata || empty($metadata['menu'])) { + $metadata = loadMetadata($itemPath); + + // Only include if explicitly marked as menu item + // parse_ini_file returns boolean true as 1, false as empty string, and "true"/"false" as strings + if (!$metadata || !isset($metadata['menu']) || !$metadata['menu']) { continue; } - - // Check if content exists for current language - if ($ctx->currentLang !== $ctx->defaultLang && shouldHideUntranslated()) { - $hasLangContent = hasLanguageContent($itemPath, $ctx->currentLang, CONTENT_EXTENSIONS); - $hasLangMetadata = hasLanguageMetadata($itemPath, $ctx->currentLang); - - if (!$hasLangContent && !$hasLangMetadata) continue; - } - - // Extract title and build URL - $title = $metadata['title'] ?? extractTitle($itemPath, $ctx->currentLang, $ctx->defaultLang) ?? ucfirst($item); - - // Use translated slug if available - $urlSlug = ($ctx->currentLang !== $ctx->defaultLang && $metadata && isset($metadata['slug'])) - ? $metadata['slug'] - : $item; - + + // Extract title + $title = $metadata['title'] ?? extractTitle($itemPath) ?? ucfirst($item); + + // Use slug if available, otherwise use folder name + $urlSlug = ($metadata && isset($metadata['slug'])) ? $metadata['slug'] : $item; + $navItems[] = [ 'title' => $title, - 'url' => $ctx->langPrefix . '/' . urlencode($urlSlug) . '/', + 'url' => '/' . urlencode($urlSlug) . '/', 'order' => (int)($metadata['menu_order'] ?? 999) ]; } - + // Sort by menu_order usort($navItems, fn($a, $b) => $a['order'] <=> $b['order']); - + return $navItems; } diff --git a/app/context.php b/app/context.php index da3246d..d9c3a3a 100644 --- a/app/context.php +++ b/app/context.php @@ -1,5 +1,4 @@ $this->currentLang !== $this->defaultLang - ? "/{$this->currentLang}" - : ''; + // Plugin data storage + public function set(string $key, mixed $value): void { + $this->data[$key] = $value; } + public function get(string $key, mixed $default = null): mixed { + return $this->data[$key] ?? $default; + } + + public function has(string $key): bool { + return isset($this->data[$key]); + } + + // Allow magic property access for plugin data + public function __get(string $name): mixed { + return $this->data[$name] ?? null; + } + + public function __set(string $name, mixed $value): void { + $this->data[$name] = $value; + } + + // Computed properties public array $navigation { get => buildNavigation($this); } public string $homeLabel { - get => loadMetadata($this->contentDir, $this->currentLang, $this->defaultLang)['slug'] ?? 'Home'; - } - - public array $translations { - get => loadTranslations($this->currentLang); + get => loadMetadata($this->contentDir)["slug"] ?? "Home"; } } diff --git a/app/docs/plugin-system.md b/app/docs/plugin-system.md new file mode 100644 index 0000000..0174010 --- /dev/null +++ b/app/docs/plugin-system.md @@ -0,0 +1,262 @@ +# Plugin System Reference + +## Overview + +The framework uses a minimal 3-hook plugin system that allows plugins to modify behavior without the core knowing about them. Plugins are PHP files that register callbacks to specific hooks. + +## Hook System + +### Available Hooks + +```php +enum Hook: string { + case CONTEXT_READY = 'context_ready'; // After context created, before routing + case PROCESS_CONTENT = 'process_content'; // When loading/processing content + case TEMPLATE_VARS = 'template_vars'; // When building template variables +} +``` + +### Core Functions + +```php +// Register a filter +Hooks::add(Hook $hook, callable $callback): void + +// Apply filters (modify and return data) +Hooks::apply(Hook $hook, mixed $value, mixed ...$args): mixed +``` + +## Creating a Plugin + +### Plugin Location + +``` +custom/plugins/global/your-plugin.php # Custom plugins +app/plugins/global/your-plugin.php # Default plugins +``` + +### Plugin Structure + +```php +set('myData', 'value'); + return $ctx; +}); + +// Hook 2: Process content/metadata +Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $context) { + // Modify content files, metadata, etc. + return $data; +}); + +// Hook 3: Add template variables +Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) { + // Add variables for templates + $vars['myVar'] = 'value'; + return $vars; +}); + +// Helper functions for your plugin +function myHelper() { + // Plugin-specific logic +} +``` + +### Enable Plugin + +Add to `custom/config.ini`: + +```ini +[plugins] +enabled = "my-plugin,another-plugin" +``` + +## Hook Details + +### 1. CONTEXT_READY + +**When:** After context created, before routing starts +**Purpose:** Modify request handling, extract data from URL, inject context properties +**Signature:** `function(Context $ctx, array $config): Context` + +**Example:** +```php +Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) { + // Extract custom parameter from URL + $parts = explode('/', $ctx->requestPath); + if ($parts[0] === 'api') { + $ctx->set('isApi', true); + array_shift($parts); + // Update request path + $reflection = new ReflectionProperty($ctx, 'requestPath'); + $reflection->setValue($ctx, implode('/', $parts)); + } + return $ctx; +}); +``` + +### 2. PROCESS_CONTENT + +**When:** During content loading and processing +**Purpose:** Filter content files, modify metadata, transform data +**Signature:** `function(mixed $data, string $context, ...): mixed` + +**Use Cases:** + +**Filter content files:** +```php +Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $dir) { + if (is_array($data) && isset($data[0]['path'])) { + // $data is array of content files + return array_filter($data, fn($file) => + !str_contains($file['name'], 'draft') + ); + } + return $data; +}); +``` + +**Modify metadata:** +```php +Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $dir, string $type = '') { + if ($type === 'metadata') { + // $data is metadata array + $data['processed'] = true; + } + return $data; +}); +``` + +**Format dates:** +```php +Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $type) { + if ($type === 'date_format') { + // $data is date string + return date('F j, Y', strtotime($data)); + } + return $data; +}); +``` + +### 3. TEMPLATE_VARS + +**When:** Before rendering templates +**Purpose:** Add variables for use in templates +**Signature:** `function(array $vars, Context $ctx): array` + +**Example:** +```php +Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) { + $vars['siteName'] = 'My Site'; + $vars['year'] = date('Y'); + $vars['customData'] = $ctx->get('myData'); + return $vars; +}); +``` + +## Context Storage + +Plugins can store data in the context using generic storage: + +```php +// Set data +$ctx->set('key', $value); + +// Get data +$value = $ctx->get('key', $default); + +// Check if exists +if ($ctx->has('key')) { } + +// Magic property access +$ctx->myKey = 'value'; +$value = $ctx->myKey; +``` + +## Best Practices + +1. **Keep plugins self-contained** - All logic in one file +2. **Use namespaced helper functions** - Prefix with plugin name +3. **Store plugin data in context** - Use `$ctx->set()` for plugin state +4. **Return modified data** - Always return from hooks +5. **Use global context when needed** - `$GLOBALS['ctx']` for cross-hook access +6. **Document your hooks** - Comment what each hook does + +## Plugin Loading Order + +1. Config loaded +2. Global plugins loaded (from config) +3. Context created +4. `CONTEXT_READY` hooks run +5. Routing happens +6. Content loaded +7. `PROCESS_CONTENT` hooks run (multiple times) +8. Template variables prepared +9. `TEMPLATE_VARS` hooks run +10. Template rendered + +## Example: Complete Plugin + +```php +set('analyticsId', $analyticsId); + return $ctx; +}); + +// Add analytics variables to templates +Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) { + $vars['analyticsId'] = $ctx->get('analyticsId'); + $vars['analyticsEnabled'] = !empty($vars['analyticsId']); + return $vars; +}); +``` + +Config: +```ini +[analytics] +id = "G-XXXXXXXXXX" + +[plugins] +enabled = "analytics" +``` + +Template usage: +```php + + + +``` + +## Debugging + +Check loaded plugins: +```php +$plugins = getPluginManager()->getLoadedPlugins(); +var_dump($plugins); +``` + +Check if plugin loaded: +```php +if (getPluginManager()->isLoaded('my-plugin')) { + // Plugin is active +} +``` + +## Limitations + +- No priorities (hooks run in registration order) +- No actions (only filters that return values) +- No unhooking (once registered, always runs) +- Plugins load once per request + +For advanced needs, consider multiple plugins or extending the hook system. diff --git a/app/helpers.php b/app/helpers.php index 94a1fd5..a84c786 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -14,8 +14,8 @@ function getSubdirectories(string $dir): array { ); } -function extractTitle(string $filePath, string $lang, string $defaultLang): ?string { - $files = findAllContentFiles($filePath, $lang, $defaultLang, []); +function extractTitle(string $filePath): ?string { + $files = findAllContentFiles($filePath); if (empty($files)) return null; // Check the first content file for a title @@ -32,17 +32,16 @@ function extractTitle(string $filePath, string $lang, string $defaultLang): ?str return null; } - - -function extractDateFromFolder(string $folderName, string $lang): ?string { +function extractDateFromFolder(string $folderName): ?string { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})-/', $folderName, $matches)) { - return formatDate($matches[1] . '-' . $matches[2] . '-' . $matches[3], $lang); + $dateString = $matches[1] . '-' . $matches[2] . '-' . $matches[3]; + // Let plugins format the date + return Hooks::apply(Hook::PROCESS_CONTENT, $dateString, 'date_format'); } return null; } function findCoverImage(string $dirPath): ?string { - // PHP 8.4: array_find() - cleaner than foreach $found = array_find( COVER_IMAGE_EXTENSIONS, fn($ext) => file_exists("$dirPath/cover.$ext") @@ -51,7 +50,6 @@ function findCoverImage(string $dirPath): ?string { } function findPdfFile(string $dirPath): ?string { - // PHP 8.4: array_find() with glob $pdfs = glob("$dirPath/*.pdf") ?: []; return $pdfs ? basename($pdfs[0]) : null; } @@ -61,54 +59,51 @@ function findPageCss(string $dirPath, string $contentDir): ?array { if (!file_exists($cssFile) || !is_file($cssFile)) { return null; } - - // Generate URL path relative to content directory + $relativePath = str_replace($contentDir, '', $dirPath); $relativePath = trim($relativePath, '/'); $cssUrl = '/' . ($relativePath ? $relativePath . '/' : '') . 'styles.css'; - + return [ 'url' => $cssUrl, 'hash' => hash_file('md5', $cssFile) ]; } -function extractMetaDescription(string $dirPath, ?array $metadata, string $lang, string $defaultLang): ?string { +function extractMetaDescription(string $dirPath, ?array $metadata): ?string { // 1. Check for search_description in metadata if ($metadata && isset($metadata['search_description'])) { return $metadata['search_description']; } - + // 2. Fall back to summary in metadata if ($metadata && isset($metadata['summary'])) { return $metadata['summary']; } - + // 3. Fall back to first paragraph in content files - $files = findAllContentFiles($dirPath, $lang, $defaultLang, []); + $files = findAllContentFiles($dirPath); if (empty($files)) return null; - + foreach ($files as $file) { $ext = pathinfo($file, PATHINFO_EXTENSION); $content = file_get_contents($file); - + if ($ext === 'md') { - // Skip headings and extract first paragraph $lines = explode("\n", $content); foreach ($lines as $line) { $line = trim($line); if (empty($line) || str_starts_with($line, '#')) continue; - if (strlen($line) > 20) { // Ignore very short lines + if (strlen($line) > 20) { return strip_tags($line); } } } elseif (in_array($ext, ['html', 'php'])) { - // Extract first
tag content if (preg_match('/
]*>(.*?)<\/p>/is', $content, $matches)) {
return strip_tags($matches[1]);
}
}
}
-
+
return null;
}
diff --git a/app/hooks.php b/app/hooks.php
new file mode 100644
index 0000000..fd0df4e
--- /dev/null
+++ b/app/hooks.php
@@ -0,0 +1,24 @@
+value][] = $callback;
+ }
+
+ public static function apply(Hook $hook, mixed $value, mixed ...$args): mixed {
+ if (!isset(self::$filters[$hook->value])) return $value;
+
+ foreach (self::$filters[$hook->value] as $filter) {
+ $value = $filter($value, ...$args);
+ }
+
+ return $value;
+ }
+}
diff --git a/app/rendering.php b/app/rendering.php
index da4133d..5b27ebe 100644
--- a/app/rendering.php
+++ b/app/rendering.php
@@ -2,12 +2,12 @@
function renderContentFile(string $filePath): string {
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
-
+
ob_start();
if ($ext === 'md') {
require_once __DIR__ . '/cache.php';
$cached = getCachedMarkdown($filePath);
-
+
if ($cached !== null) {
echo $cached;
} else {
@@ -25,13 +25,23 @@ function renderContentFile(string $filePath): string {
}
function renderTemplate(Context $ctx, string $content, int $statusCode = 200): void {
- // Extract all necessary variables for base template
- $currentLang = $ctx->currentLang;
+ global $GLOBALS;
+
+ // Get basic template vars
$navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel;
- $translations = $ctx->translations;
- $pageTitle = null; // No specific page title for error pages
+ $pageTitle = null;
+ // Let plugins add template variables
+ $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
+ 'content' => $content,
+ 'navigation' => $navigation,
+ 'homeLabel' => $homeLabel,
+ 'pageTitle' => $pageTitle
+ ], $ctx);
+
+ extract($templateVars);
+
http_response_code($statusCode);
include $ctx->templates->base;
exit;
@@ -48,26 +58,22 @@ function renderFile(Context $ctx, string $filePath): void {
if (in_array($ext, CONTENT_EXTENSIONS)) {
$content = renderContentFile($realPath);
- // Prepare template variables using property hooks
- $currentLang = $ctx->currentLang;
- $navigation = $ctx->navigation;
- $homeLabel = $ctx->homeLabel;
- $translations = $ctx->translations;
-
$pageDir = dirname($realPath);
- $pageMetadata = loadMetadata($pageDir, $ctx->currentLang, $ctx->defaultLang);
-
+ $pageMetadata = loadMetadata($pageDir);
+
// Load page-level plugins
getPluginManager()->loadPagePlugins($pageMetadata);
-
+
+ $navigation = $ctx->navigation;
+ $homeLabel = $ctx->homeLabel;
$pageTitle = $pageMetadata['title'] ?? null;
- $metaDescription = extractMetaDescription($pageDir, $pageMetadata, $ctx->currentLang, $ctx->defaultLang);
-
+ $metaDescription = extractMetaDescription($pageDir, $pageMetadata);
+
// Check for page-specific CSS
$pageCss = findPageCss($pageDir, $ctx->contentDir);
$pageCssUrl = $pageCss['url'] ?? null;
$pageCssHash = $pageCss['hash'] ?? null;
-
+
// Check for cover image for social media
$coverImage = findCoverImage($pageDir);
$socialImageUrl = null;
@@ -77,54 +83,55 @@ function renderFile(Context $ctx, string $filePath): void {
$socialImageUrl = '/' . ($relativePath ? $relativePath . '/' : '') . $coverImage;
}
+ // Let plugins add template variables
+ $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
+ 'content' => $content,
+ 'navigation' => $navigation,
+ 'homeLabel' => $homeLabel,
+ 'pageTitle' => $pageTitle,
+ 'metaDescription' => $metaDescription,
+ 'pageCssUrl' => $pageCssUrl,
+ 'pageCssHash' => $pageCssHash,
+ 'socialImageUrl' => $socialImageUrl
+ ], $ctx);
+
+ extract($templateVars);
+
// Wrap content with page template
ob_start();
- include $ctx->templates->page;
- $content = ob_get_clean();
+ require $ctx->templates->page;
+ $wrappedContent = ob_get_clean();
- // Wrap with base template
include $ctx->templates->base;
exit;
}
- // Serve other file types directly
- header('Content-Type: ' . (mime_content_type($realPath) ?: 'application/octet-stream'));
- readfile($realPath);
- exit;
+ // Unknown type - 404
+ renderTemplate($ctx, " The requested file could not be found. Access denied.404 - Not Found
403 Forbidden
The requested resource was not found.
", 404); + http_response_code(404); + renderTemplate($ctx, "The requested page could not be found.
", 404); + break; }