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, "

404 - Not Found

The requested file could not be found.

", 404); } -function renderMultipleFiles(Context $ctx, array $filePaths, string $pageDir): void { - // Validate all files are safe - foreach ($filePaths as $filePath) { - $realPath = realpath($filePath); - if (!$realPath || !str_starts_with($realPath, $ctx->contentDir) || !is_readable($realPath)) { - renderTemplate($ctx, "

403 Forbidden

Access denied.

", 403); - } +function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void { + $content = ''; + foreach ($files as $file) { + $content .= renderContentFile($file); } - // Render all content files in order - $content = implode('', array_map('renderContentFile', $filePaths)); - - // Prepare template variables using property hooks - $currentLang = $ctx->currentLang; - $navigation = $ctx->navigation; - $homeLabel = $ctx->homeLabel; - $translations = $ctx->translations; - - $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 + + // Check for cover image $coverImage = findCoverImage($pageDir); $socialImageUrl = null; if ($coverImage) { @@ -133,12 +140,25 @@ function renderMultipleFiles(Context $ctx, array $filePaths, string $pageDir): v $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; } diff --git a/app/router.php b/app/router.php index a69a161..6047183 100644 --- a/app/router.php +++ b/app/router.php @@ -2,6 +2,7 @@ // Load modular components require_once __DIR__ . '/constants.php'; +require_once __DIR__ . '/hooks.php'; require_once __DIR__ . '/context.php'; require_once __DIR__ . '/helpers.php'; require_once __DIR__ . '/plugins.php'; @@ -9,9 +10,12 @@ require_once __DIR__ . '/config.php'; require_once __DIR__ . '/content.php'; require_once __DIR__ . '/rendering.php'; -// Create context - no more globals! +// Create context $ctx = createContext(); +// Store globally for easy access +$GLOBALS['ctx'] = $ctx; + // Check for assets in /custom/assets/ served at root level $assetPath = dirname(__DIR__) . '/custom/assets/' . $ctx->requestPath; if (file_exists($assetPath) && is_file($assetPath)) { @@ -22,7 +26,7 @@ if (file_exists($assetPath) && is_file($assetPath)) { // Handle frontpage if (empty($ctx->requestPath)) { - $contentFiles = findAllContentFiles($ctx->contentDir, $ctx->currentLang, $ctx->defaultLang, $ctx->availableLangs); + $contentFiles = findAllContentFiles($ctx->contentDir); if (!empty($contentFiles)) { renderMultipleFiles($ctx, $contentFiles, $ctx->contentDir); } @@ -33,50 +37,43 @@ $parsedPath = parseRequestPath($ctx); 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($ctx, $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); - exit; - } - renderFile($ctx, $parsedPath['path']); - - case 'directory': $dir = $parsedPath['path']; - if (file_exists("$dir/index.php")) { - renderFile($ctx, "$dir/index.php"); + + // Redirect to add trailing slash if needed + if (!$ctx->hasTrailingSlash) { + header('Location: ' . rtrim($_SERVER['REQUEST_URI'], '/') . '/', true, 301); + exit; } + + $contentFiles = findAllContentFiles($dir); + if (!empty($contentFiles)) { + renderMultipleFiles($ctx, $contentFiles, $dir); + } + break; + case 'list': + $dir = $parsedPath['path']; + // Check for page content files in this directory $pageContent = null; - $contentFiles = findAllContentFiles($dir, $ctx->currentLang, $ctx->defaultLang, $ctx->availableLangs); + $contentFiles = findAllContentFiles($dir); if (!empty($contentFiles)) { $pageContent = implode('', array_map('renderContentFile', $contentFiles)); } // Load metadata for this directory - $metadata = loadMetadata($dir, $ctx->currentLang, $ctx->defaultLang); + $metadata = loadMetadata($dir); // Select list template based on metadata page_template $listTemplate = $ctx->templates->list; if (isset($metadata['page_template']) && !empty($metadata['page_template'])) { $templateName = $metadata['page_template']; - // Add .php extension if not present if (!str_ends_with($templateName, '.php')) { - $templateName = str_replace('.php', '', $templateName); + $templateName .= ''; } $customTemplate = dirname(__DIR__) . "/custom/templates/$templateName.php"; $defaultTemplate = __DIR__ . "/default/templates/$templateName.php"; - + if (file_exists($customTemplate)) { $listTemplate = $customTemplate; } elseif (file_exists($defaultTemplate)) { @@ -89,73 +86,70 @@ switch ($parsedPath['type']) { $items = array_filter(array_map(function($item) use ($dir, $ctx) { $itemPath = "$dir/$item"; - - // 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) return null; - } - - $metadata = loadMetadata($itemPath, $ctx->currentLang, $ctx->defaultLang); + $metadata = loadMetadata($itemPath); $coverImage = findCoverImage($itemPath); $pdfFile = findPdfFile($itemPath); - $title = $metadata['title'] ?? extractTitle($itemPath, $ctx->currentLang, $ctx->defaultLang) ?? $item; + $title = $metadata['title'] ?? extractTitle($itemPath) ?? $item; $date = null; if (isset($metadata['date'])) { - $date = formatDate($metadata['date'], $ctx->currentLang); + $date = $metadata['date']; + // Let plugins format date + $date = Hooks::apply(Hook::PROCESS_CONTENT, $date, 'date_format'); } else { - $date = extractDateFromFolder($item, $ctx->currentLang) ?: date("F d, Y", filemtime($itemPath)); + $date = extractDateFromFolder($item) ?: date("F d, Y", filemtime($itemPath)); } - // Use translated slug if available, otherwise use folder name - $urlSlug = ($ctx->currentLang !== $ctx->defaultLang && $metadata && isset($metadata['slug'])) - ? $metadata['slug'] - : $item; + // Use slug if available + $urlSlug = ($metadata && isset($metadata['slug'])) ? $metadata['slug'] : $item; - $baseUrl = $ctx->langPrefix . '/' . trim($ctx->requestPath, '/') . '/' . urlencode($urlSlug); + $baseUrl = '/' . trim($ctx->requestPath, '/') . '/' . urlencode($urlSlug); return [ 'title' => $title, + 'url' => $baseUrl . '/', 'date' => $date, - 'url' => $baseUrl, - 'cover' => $coverImage ? "$baseUrl/$coverImage" : null, 'summary' => $metadata['summary'] ?? null, + 'cover' => $coverImage ? "$baseUrl/$coverImage" : null, 'pdf' => $pdfFile ? "$baseUrl/$pdfFile" : null, 'redirect' => $metadata['redirect'] ?? null ]; }, $subdirs)); - ob_start(); - include $listTemplate; - $content = ob_get_clean(); + // Sort by date (newest first) if dates are present + usort($items, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? '')); // Prepare all variables for base template - $currentLang = $ctx->currentLang; $navigation = $ctx->navigation; $homeLabel = $ctx->homeLabel; - $translations = $ctx->translations; $pageTitle = $metadata['title'] ?? null; - $metaDescription = extractMetaDescription($dir, $metadata, $ctx->currentLang, $ctx->defaultLang); + $metaDescription = extractMetaDescription($dir, $metadata); // Check for page-specific CSS $pageCss = findPageCss($dir, $ctx->contentDir); - $pageCssUrl = $pageCss['url'] ?? null; - $pageCssHash = $pageCss['hash'] ?? null; - // Check for cover image for social media - $coverImage = findCoverImage($dir); - $socialImageUrl = null; - if ($coverImage) { - $relativePath = str_replace($ctx->contentDir, '', $dir); - $relativePath = trim($relativePath, '/'); - $socialImageUrl = '/' . ($relativePath ? $relativePath . '/' : '') . $coverImage; - } + // Let plugins add template variables + $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [ + 'navigation' => $navigation, + 'homeLabel' => $homeLabel, + 'pageTitle' => $pageTitle, + 'metaDescription' => $metaDescription, + 'pageCss' => $pageCss, + 'items' => $items, + 'pageContent' => $pageContent + ], $ctx); + + extract($templateVars); + + ob_start(); + require $listTemplate; + $content = ob_get_clean(); - include $ctx->templates->base; - exit; + renderTemplate($ctx, $content); + break; case 'not_found': - renderTemplate($ctx, "

404 Not Found

The requested resource was not found.

", 404); + http_response_code(404); + renderTemplate($ctx, "

404 - Page Not Found

The requested page could not be found.

", 404); + break; }