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:
parent
24ee209e17
commit
a205f2cbd7
8 changed files with 524 additions and 315 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
179
app/content.php
179
app/content.php
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
|
||||
// Find all content files in a directory (supporting language variants)
|
||||
function findAllContentFiles(string $dir, string $lang, string $defaultLang, array $availableLangs): array {
|
||||
// Find all content files in a directory
|
||||
function findAllContentFiles(string $dir): array {
|
||||
if (!is_dir($dir)) return [];
|
||||
|
||||
$files = scandir($dir) ?: [];
|
||||
|
|
@ -16,179 +16,90 @@ function findAllContentFiles(string $dir, string $lang, string $defaultLang, arr
|
|||
$filePath = "$dir/$file";
|
||||
if (!is_file($filePath)) continue;
|
||||
|
||||
// Parse filename to check for language variant
|
||||
$parts = explode('.', $file);
|
||||
|
||||
// Check if this is a language-specific file
|
||||
if (count($parts) >= 3) {
|
||||
// Pattern: name.lang.ext
|
||||
$fileLang = $parts[count($parts) - 2];
|
||||
if (in_array($fileLang, $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, '/');
|
||||
$requestPath = $ctx->requestPath;
|
||||
|
||||
if (is_file($contentPath)) {
|
||||
return ['type' => 'file', 'path' => realpath($contentPath)];
|
||||
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));
|
||||
$items = scandir($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 ($hasSubdirs) {
|
||||
return ['type' => 'directory', 'path' => realpath($contentPath)];
|
||||
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]);
|
||||
}
|
||||
// Store full metadata for plugins to access
|
||||
$baseMetadata['_raw'] = $metadata;
|
||||
|
||||
return $baseMetadata ?: null;
|
||||
// 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) {
|
||||
$itemPath = "{$ctx->contentDir}/$item";
|
||||
$metadata = loadMetadata($itemPath, $ctx->currentLang, $ctx->defaultLang);
|
||||
if ($item === '.' || $item === '..' || !is_dir($ctx->contentDir . "/$item")) continue;
|
||||
|
||||
// Check if this item should be in menu
|
||||
if (!$metadata || empty($metadata['menu'])) {
|
||||
$itemPath = "{$ctx->contentDir}/$item";
|
||||
$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);
|
||||
// Extract title
|
||||
$title = $metadata['title'] ?? extractTitle($itemPath) ?? ucfirst($item);
|
||||
|
||||
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;
|
||||
// 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)
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
readonly class Templates {
|
||||
public function __construct(
|
||||
public string $base,
|
||||
|
|
@ -9,33 +8,43 @@ readonly class Templates {
|
|||
}
|
||||
|
||||
class Context {
|
||||
// Use asymmetric visibility for immutability (PHP 8.4)
|
||||
private array $data = [];
|
||||
|
||||
public function __construct(
|
||||
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) string $requestPath,
|
||||
public private(set) bool $hasTrailingSlash
|
||||
) {}
|
||||
|
||||
// Property hooks - computed properties (PHP 8.4)
|
||||
public string $langPrefix {
|
||||
get => $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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
262
app/docs/plugin-system.md
Normal file
262
app/docs/plugin-system.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -62,7 +60,6 @@ function findPageCss(string $dirPath, string $contentDir): ?array {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Generate URL path relative to content directory
|
||||
$relativePath = str_replace($contentDir, '', $dirPath);
|
||||
$relativePath = trim($relativePath, '/');
|
||||
$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
|
||||
if ($metadata && isset($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
|
||||
$files = findAllContentFiles($dirPath, $lang, $defaultLang, []);
|
||||
$files = findAllContentFiles($dirPath);
|
||||
if (empty($files)) return null;
|
||||
|
||||
foreach ($files as $file) {
|
||||
|
|
@ -93,17 +90,15 @@ function extractMetaDescription(string $dirPath, ?array $metadata, string $lang,
|
|||
$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 <p> tag content
|
||||
if (preg_match('/<p[^>]*>(.*?)<\/p>/is', $content, $matches)) {
|
||||
return strip_tags($matches[1]);
|
||||
}
|
||||
|
|
|
|||
24
app/hooks.php
Normal file
24
app/hooks.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,12 +25,22 @@ 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;
|
||||
|
|
@ -48,20 +58,16 @@ 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);
|
||||
|
|
@ -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, "<article><h1>404 - Not Found</h1><p>The requested file could not be found.</p></article>", 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, "<article><h1>403 Forbidden</h1><p>Access denied.</p></article>", 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;
|
||||
}
|
||||
|
|
|
|||
122
app/router.php
122
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,46 +37,39 @@ $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";
|
||||
|
|
@ -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);
|
||||
|
||||
include $ctx->templates->base;
|
||||
exit;
|
||||
extract($templateVars);
|
||||
|
||||
ob_start();
|
||||
require $listTemplate;
|
||||
$content = ob_get_clean();
|
||||
|
||||
renderTemplate($ctx, $content);
|
||||
break;
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue