# 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

min read ( words)

``` 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
``` ### 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