Remove language-specific content handling

Refactor to use plugin system for language support

Remove hardcoded language features from core

Move language handling to plugin system

Improve content file discovery

Simplify context creation

Add plugin system documentation

Implement hook system for extensibility

Add template variable hook

Add context storage for plugins

Improve error handling

Refactor rendering logic

Improve list view sorting

Add support for custom list templates

Improve metadata handling

Add plugin system reference documentation
This commit is contained in:
Ruben 2025-11-25 20:19:12 +01:00
parent 24ee209e17
commit a205f2cbd7
8 changed files with 524 additions and 315 deletions

View file

@ -10,9 +10,6 @@ function createContext(): Context {
// Load global plugins // Load global plugins
getPluginManager()->loadGlobalPlugins($config); 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 // Use user content if exists and has content, otherwise fall back to demo content
$userContentDir = $_SERVER['DOCUMENT_ROOT']; $userContentDir = $_SERVER['DOCUMENT_ROOT'];
$demoContentDir = __DIR__ . '/default/content'; $demoContentDir = __DIR__ . '/default/content';
@ -27,15 +24,6 @@ function createContext(): Context {
$hasTrailingSlash = str_ends_with($requestUri, '/') && $requestUri !== '/'; $hasTrailingSlash = str_ends_with($requestUri, '/') && $requestUri !== '/';
$requestPath = trim($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 // Resolve templates with custom fallback to defaults
$templates = new Templates( $templates = new Templates(
base: resolveTemplate('base'), base: resolveTemplate('base'),
@ -43,13 +31,19 @@ function createContext(): Context {
list: resolveTemplate('list') list: resolveTemplate('list')
); );
return new Context( // Create base context
$ctx = new Context(
contentDir: $contentDir, contentDir: $contentDir,
currentLang: $currentLang,
defaultLang: $defaultLang,
availableLangs: $availableLangs,
templates: $templates, templates: $templates,
requestPath: $requestPath, requestPath: $requestPath,
hasTrailingSlash: $hasTrailingSlash 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;
} }

View file

@ -1,7 +1,7 @@
<?php <?php
// Find all content files in a directory (supporting language variants) // Find all content files in a directory
function findAllContentFiles(string $dir, string $lang, string $defaultLang, array $availableLangs): array { function findAllContentFiles(string $dir): array {
if (!is_dir($dir)) return []; if (!is_dir($dir)) return [];
$files = scandir($dir) ?: []; $files = scandir($dir) ?: [];
@ -16,179 +16,90 @@ function findAllContentFiles(string $dir, string $lang, string $defaultLang, arr
$filePath = "$dir/$file"; $filePath = "$dir/$file";
if (!is_file($filePath)) continue; 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, $availableLangs)) {
// Only include if it matches current language
if ($fileLang === $lang) {
$contentFiles[] = [ $contentFiles[] = [
'path' => $filePath, 'path' => $filePath,
'name' => $file, 'name' => $file,
'sort_key' => $parts[0] 'ext' => $ext
]; ];
} }
continue;
}
}
// Default files (no language suffix) - include if no language-specific version exists // Let plugins filter content files (e.g., by language)
$baseName = $parts[0]; $contentFiles = Hooks::apply(Hook::PROCESS_CONTENT, $contentFiles, $dir);
$hasLangVersion = false;
if ($lang !== $defaultLang) { // Sort by filename
// Check if language-specific version exists usort($contentFiles, fn($a, $b) => strnatcmp($a['name'], $b['name']));
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
];
}
}
// Sort by filename (alphanumerical)
usort($contentFiles, fn($a, $b) => strnatcmp($a['sort_key'], $b['sort_key']));
return array_column($contentFiles, 'path'); 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 { function parseRequestPath(Context $ctx): array {
// Resolve translated slugs to actual directory names $requestPath = $ctx->requestPath;
$resolvedPath = resolveTranslatedPath($ctx, $ctx->requestPath);
$contentPath = rtrim($ctx->contentDir, '/') . '/' . ltrim($resolvedPath, '/');
if (is_file($contentPath)) { if (empty($requestPath)) {
return ['type' => 'file', 'path' => realpath($contentPath)]; return ['type' => 'frontpage', 'path' => $ctx->contentDir];
} }
$contentPath = $ctx->contentDir . '/' . $requestPath;
// Check if it's a directory
if (is_dir($contentPath)) { if (is_dir($contentPath)) {
// Check if directory has subdirectories (PHP 8.4: cleaner with array_any later) $items = scandir($contentPath) ?: [];
$hasSubdirs = !empty(getSubdirectories($contentPath)); $subdirs = array_filter($items, fn($item) =>
$item !== '.' && $item !== '..' && is_dir("$contentPath/$item")
);
// If directory has subdirectories, it's an article-type folder (list view) if (!empty($subdirs)) {
if ($hasSubdirs) { return ['type' => 'list', 'path' => $contentPath];
return ['type' => 'directory', 'path' => realpath($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]; return ['type' => 'not_found', 'path' => $contentPath];
} }
function loadMetadata(string $dirPath, string $lang, string $defaultLang): ?array { function loadMetadata(string $dirPath): ?array {
$metadataFile = "$dirPath/metadata.ini"; $metadataFile = "$dirPath/metadata.ini";
if (!file_exists($metadataFile)) return null; if (!file_exists($metadataFile)) return null;
$metadata = parse_ini_file($metadataFile, true); $metadata = parse_ini_file($metadataFile, true);
if (!$metadata) return null; 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); $baseMetadata = array_filter($metadata, fn($key) => !is_array($metadata[$key]), ARRAY_FILTER_USE_KEY);
// If current language is not default, merge language-specific overrides // Store full metadata for plugins to access
if ($lang !== $defaultLang && isset($metadata[$lang]) && is_array($metadata[$lang])) { $baseMetadata['_raw'] = $metadata;
$baseMetadata = array_merge($baseMetadata, $metadata[$lang]);
// Let plugins modify metadata (e.g., merge language sections)
return Hooks::apply(Hook::PROCESS_CONTENT, $baseMetadata, $dirPath, 'metadata');
} }
return $baseMetadata ?: null;
}
function buildNavigation(Context $ctx): array { function buildNavigation(Context $ctx): array {
$items = scandir($ctx->contentDir) ?: [];
$navItems = []; $navItems = [];
$items = getSubdirectories($ctx->contentDir);
foreach ($items as $item) { foreach ($items as $item) {
$itemPath = "{$ctx->contentDir}/$item"; if ($item === '.' || $item === '..' || !is_dir($ctx->contentDir . "/$item")) continue;
$metadata = loadMetadata($itemPath, $ctx->currentLang, $ctx->defaultLang);
// Check if this item should be in menu $itemPath = "{$ctx->contentDir}/$item";
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; continue;
} }
// Check if content exists for current language // Extract title
if ($ctx->currentLang !== $ctx->defaultLang && shouldHideUntranslated()) { $title = $metadata['title'] ?? extractTitle($itemPath) ?? ucfirst($item);
$hasLangContent = hasLanguageContent($itemPath, $ctx->currentLang, CONTENT_EXTENSIONS);
$hasLangMetadata = hasLanguageMetadata($itemPath, $ctx->currentLang);
if (!$hasLangContent && !$hasLangMetadata) continue; // Use slug if available, otherwise use folder name
} $urlSlug = ($metadata && isset($metadata['slug'])) ? $metadata['slug'] : $item;
// 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;
$navItems[] = [ $navItems[] = [
'title' => $title, 'title' => $title,
'url' => $ctx->langPrefix . '/' . urlencode($urlSlug) . '/', 'url' => '/' . urlencode($urlSlug) . '/',
'order' => (int)($metadata['menu_order'] ?? 999) 'order' => (int)($metadata['menu_order'] ?? 999)
]; ];
} }

View file

@ -1,5 +1,4 @@
<?php <?php
readonly class Templates { readonly class Templates {
public function __construct( public function __construct(
public string $base, public string $base,
@ -9,33 +8,43 @@ readonly class Templates {
} }
class Context { class Context {
// Use asymmetric visibility for immutability (PHP 8.4) private array $data = [];
public function __construct( public function __construct(
public private(set) string $contentDir, public private(set) string $contentDir,
public private(set) string $currentLang,
public private(set) string $defaultLang,
public private(set) array $availableLangs,
public private(set) Templates $templates, public private(set) Templates $templates,
public private(set) string $requestPath, public private(set) string $requestPath,
public private(set) bool $hasTrailingSlash public private(set) bool $hasTrailingSlash
) {} ) {}
// Property hooks - computed properties (PHP 8.4) // Plugin data storage
public string $langPrefix { public function set(string $key, mixed $value): void {
get => $this->currentLang !== $this->defaultLang $this->data[$key] = $value;
? "/{$this->currentLang}"
: '';
} }
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 { public array $navigation {
get => buildNavigation($this); get => buildNavigation($this);
} }
public string $homeLabel { public string $homeLabel {
get => loadMetadata($this->contentDir, $this->currentLang, $this->defaultLang)['slug'] ?? 'Home'; get => loadMetadata($this->contentDir)["slug"] ?? "Home";
}
public array $translations {
get => loadTranslations($this->currentLang);
} }
} }

262
app/docs/plugin-system.md Normal file
View file

@ -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
<?php
// custom/plugins/global/my-plugin.php
// Hook 1: Modify context after creation
Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
// Modify context, add data
$ctx->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
<?php
// custom/plugins/global/analytics.php
// Add analytics configuration to context
Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
$analyticsId = $config['analytics']['id'] ?? null;
$ctx->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
<?php if ($analyticsEnabled): ?>
<script async src="https://www.googletagmanager.com/gtag/js?id=<?= $analyticsId ?>"></script>
<?php endif; ?>
```
## 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.

View file

@ -14,8 +14,8 @@ function getSubdirectories(string $dir): array {
); );
} }
function extractTitle(string $filePath, string $lang, string $defaultLang): ?string { function extractTitle(string $filePath): ?string {
$files = findAllContentFiles($filePath, $lang, $defaultLang, []); $files = findAllContentFiles($filePath);
if (empty($files)) return null; if (empty($files)) return null;
// Check the first content file for a title // Check the first content file for a title
@ -32,17 +32,16 @@ function extractTitle(string $filePath, string $lang, string $defaultLang): ?str
return null; return null;
} }
function extractDateFromFolder(string $folderName): ?string {
function extractDateFromFolder(string $folderName, string $lang): ?string {
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})-/', $folderName, $matches)) { 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; return null;
} }
function findCoverImage(string $dirPath): ?string { function findCoverImage(string $dirPath): ?string {
// PHP 8.4: array_find() - cleaner than foreach
$found = array_find( $found = array_find(
COVER_IMAGE_EXTENSIONS, COVER_IMAGE_EXTENSIONS,
fn($ext) => file_exists("$dirPath/cover.$ext") fn($ext) => file_exists("$dirPath/cover.$ext")
@ -51,7 +50,6 @@ function findCoverImage(string $dirPath): ?string {
} }
function findPdfFile(string $dirPath): ?string { function findPdfFile(string $dirPath): ?string {
// PHP 8.4: array_find() with glob
$pdfs = glob("$dirPath/*.pdf") ?: []; $pdfs = glob("$dirPath/*.pdf") ?: [];
return $pdfs ? basename($pdfs[0]) : null; return $pdfs ? basename($pdfs[0]) : null;
} }
@ -62,7 +60,6 @@ function findPageCss(string $dirPath, string $contentDir): ?array {
return null; return null;
} }
// Generate URL path relative to content directory
$relativePath = str_replace($contentDir, '', $dirPath); $relativePath = str_replace($contentDir, '', $dirPath);
$relativePath = trim($relativePath, '/'); $relativePath = trim($relativePath, '/');
$cssUrl = '/' . ($relativePath ? $relativePath . '/' : '') . 'styles.css'; $cssUrl = '/' . ($relativePath ? $relativePath . '/' : '') . 'styles.css';
@ -73,7 +70,7 @@ function findPageCss(string $dirPath, string $contentDir): ?array {
]; ];
} }
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 // 1. Check for search_description in metadata
if ($metadata && isset($metadata['search_description'])) { if ($metadata && isset($metadata['search_description'])) {
return $metadata['search_description']; return $metadata['search_description'];
@ -85,7 +82,7 @@ function extractMetaDescription(string $dirPath, ?array $metadata, string $lang,
} }
// 3. Fall back to first paragraph in content files // 3. Fall back to first paragraph in content files
$files = findAllContentFiles($dirPath, $lang, $defaultLang, []); $files = findAllContentFiles($dirPath);
if (empty($files)) return null; if (empty($files)) return null;
foreach ($files as $file) { foreach ($files as $file) {
@ -93,17 +90,15 @@ function extractMetaDescription(string $dirPath, ?array $metadata, string $lang,
$content = file_get_contents($file); $content = file_get_contents($file);
if ($ext === 'md') { if ($ext === 'md') {
// Skip headings and extract first paragraph
$lines = explode("\n", $content); $lines = explode("\n", $content);
foreach ($lines as $line) { foreach ($lines as $line) {
$line = trim($line); $line = trim($line);
if (empty($line) || str_starts_with($line, '#')) continue; if (empty($line) || str_starts_with($line, '#')) continue;
if (strlen($line) > 20) { // Ignore very short lines if (strlen($line) > 20) {
return strip_tags($line); return strip_tags($line);
} }
} }
} elseif (in_array($ext, ['html', 'php'])) { } elseif (in_array($ext, ['html', 'php'])) {
// Extract first <p> tag content
if (preg_match('/<p[^>]*>(.*?)<\/p>/is', $content, $matches)) { if (preg_match('/<p[^>]*>(.*?)<\/p>/is', $content, $matches)) {
return strip_tags($matches[1]); return strip_tags($matches[1]);
} }

24
app/hooks.php Normal file
View file

@ -0,0 +1,24 @@
<?php
enum Hook: string {
case CONTEXT_READY = 'context_ready';
case PROCESS_CONTENT = 'process_content';
case TEMPLATE_VARS = 'template_vars';
}
class Hooks {
private static array $filters = [];
public static function add(Hook $hook, callable $callback): void {
self::$filters[$hook->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;
}
}

View file

@ -25,12 +25,22 @@ function renderContentFile(string $filePath): string {
} }
function renderTemplate(Context $ctx, string $content, int $statusCode = 200): void { function renderTemplate(Context $ctx, string $content, int $statusCode = 200): void {
// Extract all necessary variables for base template global $GLOBALS;
$currentLang = $ctx->currentLang;
// Get basic template vars
$navigation = $ctx->navigation; $navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel; $homeLabel = $ctx->homeLabel;
$translations = $ctx->translations; $pageTitle = null;
$pageTitle = null; // No specific page title for error pages
// 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); http_response_code($statusCode);
include $ctx->templates->base; include $ctx->templates->base;
@ -48,76 +58,16 @@ function renderFile(Context $ctx, string $filePath): void {
if (in_array($ext, CONTENT_EXTENSIONS)) { if (in_array($ext, CONTENT_EXTENSIONS)) {
$content = renderContentFile($realPath); $content = renderContentFile($realPath);
// Prepare template variables using property hooks
$currentLang = $ctx->currentLang;
$navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel;
$translations = $ctx->translations;
$pageDir = dirname($realPath); $pageDir = dirname($realPath);
$pageMetadata = loadMetadata($pageDir, $ctx->currentLang, $ctx->defaultLang); $pageMetadata = loadMetadata($pageDir);
// Load page-level plugins // Load page-level plugins
getPluginManager()->loadPagePlugins($pageMetadata); getPluginManager()->loadPagePlugins($pageMetadata);
$pageTitle = $pageMetadata['title'] ?? null;
$metaDescription = extractMetaDescription($pageDir, $pageMetadata, $ctx->currentLang, $ctx->defaultLang);
// 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;
if ($coverImage) {
$relativePath = str_replace($ctx->contentDir, '', $pageDir);
$relativePath = trim($relativePath, '/');
$socialImageUrl = '/' . ($relativePath ? $relativePath . '/' : '') . $coverImage;
}
// Wrap content with page template
ob_start();
include $ctx->templates->page;
$content = 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;
}
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, "<article><h1>403 Forbidden</h1><p>Access denied.</p></article>", 403);
}
}
// Render all content files in order
$content = implode('', array_map('renderContentFile', $filePaths));
// Prepare template variables using property hooks
$currentLang = $ctx->currentLang;
$navigation = $ctx->navigation; $navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel; $homeLabel = $ctx->homeLabel;
$translations = $ctx->translations;
$pageMetadata = loadMetadata($pageDir, $ctx->currentLang, $ctx->defaultLang);
// Load page-level plugins
getPluginManager()->loadPagePlugins($pageMetadata);
$pageTitle = $pageMetadata['title'] ?? null; $pageTitle = $pageMetadata['title'] ?? null;
$metaDescription = extractMetaDescription($pageDir, $pageMetadata, $ctx->currentLang, $ctx->defaultLang); $metaDescription = extractMetaDescription($pageDir, $pageMetadata);
// Check for page-specific CSS // Check for page-specific CSS
$pageCss = findPageCss($pageDir, $ctx->contentDir); $pageCss = findPageCss($pageDir, $ctx->contentDir);
@ -133,12 +83,82 @@ function renderMultipleFiles(Context $ctx, array $filePaths, string $pageDir): v
$socialImageUrl = '/' . ($relativePath ? $relativePath . '/' : '') . $coverImage; $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 // Wrap content with page template
ob_start(); ob_start();
include $ctx->templates->page; require $ctx->templates->page;
$content = ob_get_clean(); $wrappedContent = ob_get_clean();
include $ctx->templates->base;
exit;
}
// Unknown type - 404
renderTemplate($ctx, "<article><h1>404 - Not Found</h1><p>The requested file could not be found.</p></article>", 404);
}
function renderMultipleFiles(Context $ctx, array $files, string $pageDir): void {
$content = '';
foreach ($files as $file) {
$content .= renderContentFile($file);
}
$pageMetadata = loadMetadata($pageDir);
// Load page-level plugins
getPluginManager()->loadPagePlugins($pageMetadata);
$navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel;
$pageTitle = $pageMetadata['title'] ?? null;
$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
$coverImage = findCoverImage($pageDir);
$socialImageUrl = null;
if ($coverImage) {
$relativePath = str_replace($ctx->contentDir, '', $pageDir);
$relativePath = trim($relativePath, '/');
$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();
require $ctx->templates->page;
$wrappedContent = ob_get_clean();
// Wrap with base template
include $ctx->templates->base; include $ctx->templates->base;
exit; exit;
} }

View file

@ -2,6 +2,7 @@
// Load modular components // Load modular components
require_once __DIR__ . '/constants.php'; require_once __DIR__ . '/constants.php';
require_once __DIR__ . '/hooks.php';
require_once __DIR__ . '/context.php'; require_once __DIR__ . '/context.php';
require_once __DIR__ . '/helpers.php'; require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/plugins.php'; require_once __DIR__ . '/plugins.php';
@ -9,9 +10,12 @@ require_once __DIR__ . '/config.php';
require_once __DIR__ . '/content.php'; require_once __DIR__ . '/content.php';
require_once __DIR__ . '/rendering.php'; require_once __DIR__ . '/rendering.php';
// Create context - no more globals! // Create context
$ctx = createContext(); $ctx = createContext();
// Store globally for easy access
$GLOBALS['ctx'] = $ctx;
// Check for assets in /custom/assets/ served at root level // Check for assets in /custom/assets/ served at root level
$assetPath = dirname(__DIR__) . '/custom/assets/' . $ctx->requestPath; $assetPath = dirname(__DIR__) . '/custom/assets/' . $ctx->requestPath;
if (file_exists($assetPath) && is_file($assetPath)) { if (file_exists($assetPath) && is_file($assetPath)) {
@ -22,7 +26,7 @@ if (file_exists($assetPath) && is_file($assetPath)) {
// Handle frontpage // Handle frontpage
if (empty($ctx->requestPath)) { if (empty($ctx->requestPath)) {
$contentFiles = findAllContentFiles($ctx->contentDir, $ctx->currentLang, $ctx->defaultLang, $ctx->availableLangs); $contentFiles = findAllContentFiles($ctx->contentDir);
if (!empty($contentFiles)) { if (!empty($contentFiles)) {
renderMultipleFiles($ctx, $contentFiles, $ctx->contentDir); renderMultipleFiles($ctx, $contentFiles, $ctx->contentDir);
} }
@ -33,46 +37,39 @@ $parsedPath = parseRequestPath($ctx);
switch ($parsedPath['type']) { switch ($parsedPath['type']) {
case 'page': 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']; $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 // Check for page content files in this directory
$pageContent = null; $pageContent = null;
$contentFiles = findAllContentFiles($dir, $ctx->currentLang, $ctx->defaultLang, $ctx->availableLangs); $contentFiles = findAllContentFiles($dir);
if (!empty($contentFiles)) { if (!empty($contentFiles)) {
$pageContent = implode('', array_map('renderContentFile', $contentFiles)); $pageContent = implode('', array_map('renderContentFile', $contentFiles));
} }
// Load metadata for this directory // Load metadata for this directory
$metadata = loadMetadata($dir, $ctx->currentLang, $ctx->defaultLang); $metadata = loadMetadata($dir);
// Select list template based on metadata page_template // Select list template based on metadata page_template
$listTemplate = $ctx->templates->list; $listTemplate = $ctx->templates->list;
if (isset($metadata['page_template']) && !empty($metadata['page_template'])) { if (isset($metadata['page_template']) && !empty($metadata['page_template'])) {
$templateName = $metadata['page_template']; $templateName = $metadata['page_template'];
// Add .php extension if not present
if (!str_ends_with($templateName, '.php')) { if (!str_ends_with($templateName, '.php')) {
$templateName = str_replace('.php', '', $templateName); $templateName .= '';
} }
$customTemplate = dirname(__DIR__) . "/custom/templates/$templateName.php"; $customTemplate = dirname(__DIR__) . "/custom/templates/$templateName.php";
$defaultTemplate = __DIR__ . "/default/templates/$templateName.php"; $defaultTemplate = __DIR__ . "/default/templates/$templateName.php";
@ -89,73 +86,70 @@ switch ($parsedPath['type']) {
$items = array_filter(array_map(function($item) use ($dir, $ctx) { $items = array_filter(array_map(function($item) use ($dir, $ctx) {
$itemPath = "$dir/$item"; $itemPath = "$dir/$item";
$metadata = loadMetadata($itemPath);
// 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);
$coverImage = findCoverImage($itemPath); $coverImage = findCoverImage($itemPath);
$pdfFile = findPdfFile($itemPath); $pdfFile = findPdfFile($itemPath);
$title = $metadata['title'] ?? extractTitle($itemPath, $ctx->currentLang, $ctx->defaultLang) ?? $item; $title = $metadata['title'] ?? extractTitle($itemPath) ?? $item;
$date = null; $date = null;
if (isset($metadata['date'])) { 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 { } 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 // Use slug if available
$urlSlug = ($ctx->currentLang !== $ctx->defaultLang && $metadata && isset($metadata['slug'])) $urlSlug = ($metadata && isset($metadata['slug'])) ? $metadata['slug'] : $item;
? $metadata['slug']
: $item;
$baseUrl = $ctx->langPrefix . '/' . trim($ctx->requestPath, '/') . '/' . urlencode($urlSlug); $baseUrl = '/' . trim($ctx->requestPath, '/') . '/' . urlencode($urlSlug);
return [ return [
'title' => $title, 'title' => $title,
'url' => $baseUrl . '/',
'date' => $date, 'date' => $date,
'url' => $baseUrl,
'cover' => $coverImage ? "$baseUrl/$coverImage" : null,
'summary' => $metadata['summary'] ?? null, 'summary' => $metadata['summary'] ?? null,
'cover' => $coverImage ? "$baseUrl/$coverImage" : null,
'pdf' => $pdfFile ? "$baseUrl/$pdfFile" : null, 'pdf' => $pdfFile ? "$baseUrl/$pdfFile" : null,
'redirect' => $metadata['redirect'] ?? null 'redirect' => $metadata['redirect'] ?? null
]; ];
}, $subdirs)); }, $subdirs));
ob_start(); // Sort by date (newest first) if dates are present
include $listTemplate; usort($items, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? ''));
$content = ob_get_clean();
// Prepare all variables for base template // Prepare all variables for base template
$currentLang = $ctx->currentLang;
$navigation = $ctx->navigation; $navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel; $homeLabel = $ctx->homeLabel;
$translations = $ctx->translations;
$pageTitle = $metadata['title'] ?? null; $pageTitle = $metadata['title'] ?? null;
$metaDescription = extractMetaDescription($dir, $metadata, $ctx->currentLang, $ctx->defaultLang); $metaDescription = extractMetaDescription($dir, $metadata);
// Check for page-specific CSS // Check for page-specific CSS
$pageCss = findPageCss($dir, $ctx->contentDir); $pageCss = findPageCss($dir, $ctx->contentDir);
$pageCssUrl = $pageCss['url'] ?? null;
$pageCssHash = $pageCss['hash'] ?? null;
// Check for cover image for social media // Let plugins add template variables
$coverImage = findCoverImage($dir); $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
$socialImageUrl = null; 'navigation' => $navigation,
if ($coverImage) { 'homeLabel' => $homeLabel,
$relativePath = str_replace($ctx->contentDir, '', $dir); 'pageTitle' => $pageTitle,
$relativePath = trim($relativePath, '/'); 'metaDescription' => $metaDescription,
$socialImageUrl = '/' . ($relativePath ? $relativePath . '/' : '') . $coverImage; 'pageCss' => $pageCss,
} 'items' => $items,
'pageContent' => $pageContent
], $ctx);
include $ctx->templates->base; extract($templateVars);
exit;
ob_start();
require $listTemplate;
$content = ob_get_clean();
renderTemplate($ctx, $content);
break;
case 'not_found': case 'not_found':
renderTemplate($ctx, "<h1>404 Not Found</h1><p>The requested resource was not found.</p>", 404); http_response_code(404);
renderTemplate($ctx, "<h1>404 - Page Not Found</h1><p>The requested page could not be found.</p>", 404);
break;
} }