# Plugin System
FolderWeb uses a minimal hook-based plugin system for extensibility. Plugins let you modify content, add functionality, and inject custom variables into templates—all without touching the framework code.
## How Plugins Work
Plugins are PHP files that register callbacks with one or more **hooks**:
1. **`Hook::CONTEXT_READY`** — After context is created, before routing
2. **`Hook::PROCESS_CONTENT`** — When loading/processing content
3. **`Hook::TEMPLATE_VARS`** — Before rendering templates
Each hook receives data, allows modification, and returns the modified data.
## Plugin Locations
```
app/plugins/
├── global/ # Built-in global plugins (don't modify)
│ └── languages.php
└── page/ # Built-in page plugins (empty by default)
custom/plugins/
├── global/ # Your global plugins
│ ├── analytics.php
│ └── reading-time.php
└── page/ # Your page plugins (not yet used)
```
**Global plugins:** Loaded on every request
**Page plugins:** Reserved for future use
## Enabling Plugins
List enabled plugins in `custom/config.ini`:
```ini
[plugins]
enabled = "languages,analytics,reading-time"
```
Plugin names correspond to filenames without `.php`:
- `languages` → `languages.php`
- `analytics` → `analytics.php`
- `reading-time` → `reading-time.php`
FolderWeb loads plugins from:
1. `app/plugins/global/` (built-in)
2. `custom/plugins/global/` (yours)
## The Three Hooks
### `Hook::CONTEXT_READY`
Called after the context object is created, before routing begins.
**Use for:**
- Setting global context values
- Processing configuration
- Adding cross-cutting concerns
**Signature:**
```php
Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
// Modify context
$ctx->set('key', 'value');
// Must return context
return $ctx;
});
```
**Parameters:**
- `$ctx` — Context object (see [Context API](#context-api))
- `$config` — Merged configuration array from `config.ini`
**Must return:** Modified `$ctx`
### `Hook::PROCESS_CONTENT`
Called when loading or processing content (files, metadata, dates).
**Use for:**
- Filtering content files
- Transforming metadata
- Custom content processing
**Signature:**
```php
Hooks::add(Hook::PROCESS_CONTENT, function(mixed $data, string $dirOrType, string $extraContext = '') {
// Process data based on type
if ($extraContext === 'metadata') {
// Modify metadata array
$data['custom_field'] = 'value';
}
// Must return data
return $data;
});
```
**Parameters:**
- `$data` — The data being processed (type varies)
- `$dirOrType` — Directory path or processing type
- `$extraContext` — Additional context (e.g., `"metadata"`, `"date_format"`)
**Must return:** Modified `$data`
**Common `$extraContext` values:**
- `"metadata"` — Processing metadata array
- `"date_format"` — Formatting a date string
### `Hook::TEMPLATE_VARS`
Called before rendering templates, allowing you to add variables.
**Use for:**
- Adding custom template variables
- Computing values for display
- Injecting data into templates
**Signature:**
```php
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
// Add custom variables
$vars['siteName'] = 'My Website';
$vars['currentYear'] = date('Y');
// Must return vars
return $vars;
});
```
**Parameters:**
- `$vars` — Array of template variables
- `$ctx` — Context object
**Must return:** Modified `$vars` array
## Context API
The `Context` object stores global state. Access it in hooks:
```php
// Set a value
$ctx->set('key', 'value');
// Get a value
$value = $ctx->get('key');
// Get with default
$value = $ctx->get('key', 'default');
// Check if exists
if ($ctx->has('key')) {
// ...
}
```
**Built-in context values:**
| Key | Type | Description |
|-----|------|-------------|
| `requestPath` | String | URL path (e.g., `"blog/my-post"`) |
| `contentDir` | String | Filesystem path to content |
| `currentLang` | String | Current language (from languages plugin) |
| `defaultLang` | String | Default language |
| `translations` | Array | Translated strings |
| `metadata` | Array | Current page metadata |
## Creating Your First Plugin
Let's create a plugin that adds a reading time estimate to posts.
### Step 1: Create the Plugin File
**custom/plugins/global/reading-time.php:**
```php
= $content ?>
```
Done! Every page now shows reading time.
## Plugin Examples
### Analytics Plugin
Add Google Analytics tracking ID to all pages.
**custom/plugins/global/analytics.php:**
```php
```
### Table of Contents Plugin
Generate a table of contents from headings.
**custom/plugins/global/toc.php:**
```php
(.*?)<\/h\1>/i', $content, $matches)) {
foreach ($matches[0] as $i => $match) {
$level = (int)$matches[1][$i];
$text = strip_tags($matches[2][$i]);
$id = slugify($text);
// Add ID to heading
$newHeading = str_replace('', '', $match);
$content = str_replace($match, $newHeading, $content);
$toc[] = [
'level' => $level,
'text' => $text,
'id' => $id,
];
}
}
$vars['content'] = $content;
$vars['tableOfContents'] = $toc;
return $vars;
});
function slugify(string $text): string {
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9]+/', '-', $text);
return trim($text, '-');
}
```
**Use in template:**
```php
= $content ?>
```
### Author Bio Plugin
Add author information from metadata.
**custom/plugins/global/author-bio.php:**
```php
get('metadata', []);
// Load author data if specified
if (isset($metadata['author'])) {
$authorSlug = slugify($metadata['author']);
$authorFile = dirname(__DIR__, 2) . "/content/authors/$authorSlug.ini";
if (file_exists($authorFile)) {
$authorData = parse_ini_file($authorFile);
$vars['authorBio'] = $authorData;
}
}
return $vars;
});
function slugify(string $text): string {
return strtolower(preg_replace('/[^a-z0-9]+/', '-', $text));
}
```
**content/authors/jane-doe.ini:**
```ini
name = "Jane Doe"
bio = "Web developer and writer"
email = "jane@example.com"
twitter = "@janedoe"
website = "https://janedoe.com"
```
**Use in template:**
```php
```
### Related Posts Plugin
Show related posts based on tags.
**custom/plugins/global/related-posts.php:**
```php
get('metadata', []);
// Only for pages with tags
if (!isset($metadata['tags'])) {
return $vars;
}
$currentPath = $ctx->get('currentPath', '');
$currentTags = array_map('trim', explode(',', $metadata['tags']));
// Find other posts with similar tags
$contentDir = $ctx->contentDir;
$relatedPosts = findRelatedPosts($contentDir, $currentPath, $currentTags);
if (!empty($relatedPosts)) {
$vars['relatedPosts'] = $relatedPosts;
}
return $vars;
});
function findRelatedPosts(string $contentDir, string $currentPath, array $currentTags): array {
$posts = [];
// Recursively scan content directory
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($contentDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getFilename() === 'metadata.ini') {
$dir = dirname($file->getPathname());
// Skip current page
if ($dir === $currentPath) continue;
$metadata = parse_ini_file($file->getPathname());
if (isset($metadata['tags'])) {
$tags = array_map('trim', explode(',', $metadata['tags']));
$commonTags = array_intersect($currentTags, $tags);
if (!empty($commonTags)) {
$posts[] = [
'title' => $metadata['title'] ?? basename($dir),
'url' => str_replace($contentDir, '', $dir) . '/',
'tags' => $tags,
'relevance' => count($commonTags),
];
}
}
}
}
// Sort by relevance
usort($posts, fn($a, $b) => $b['relevance'] <=> $a['relevance']);
// Return top 3
return array_slice($posts, 0, 3);
}
```
**Use in template:**
```php
```
## Best Practices
### 1. Always Return Modified Data
```php
// Good
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
$vars['custom'] = 'value';
return $vars; // Always return
});
// Bad
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
$vars['custom'] = 'value';
// Missing return - breaks other plugins!
});
```
### 2. Use Configuration for Settings
```php
// Good: configurable
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
global $config;
$wordsPerMinute = $config['reading_time']['words_per_minute'] ?? 200;
// Use $wordsPerMinute...
return $vars;
});
```
**custom/config.ini:**
```ini
[reading_time]
words_per_minute = 250
```
### 3. Check Variable Existence
```php
// Good: defensive
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
if (isset($vars['content'])) {
// Process content
}
return $vars;
});
// Bad: assumes content exists
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
$wordCount = str_word_count($vars['content']); // May error
return $vars;
});
```
### 4. Namespace Helper Functions
```php
// Good: prefixed function name
function readingTime_calculate(string $content): int {
// ...
}
// Bad: generic name (may conflict)
function calculate(string $content): int {
// ...
}
```
### 5. Use Type Hints
```php
// Good: type hints for clarity
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx): array {
$vars['custom'] = 'value';
return $vars;
});
```
## Debugging Plugins
### Check Plugin Loading
Add debug output to verify your plugin loads:
```php
```
**Remove before deploying to production.**
## Limitations
- **No inter-plugin communication:** Plugins can't directly call each other
- **Single execution order:** Hooks execute in registration order (no priority system)
- **Global scope:** Be careful with global variables and function names
- **No automatic loading:** Plugins must be listed in `config.ini`
## What's Next?
- **[Hook Reference](#)** — Detailed documentation of all hooks
- **[Example Plugins](#)** — More real-world plugin examples
- **[Contributing](#)** — Share your plugins with the community