folderweb/docs/04-development/01-plugin-system.md
Ruben 76697e4656 Add getting started documentation
Add tutorial on adding content

Add tutorial on styling

Add tutorial on templates

Add configuration reference

Add metadata reference

Add template variables reference

Add internationalization reference

Add plugin system documentation

Add creating templates documentation

Add index page
2025-11-27 23:01:02 +01:00

15 KiB

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:

[plugins]
enabled = "languages,analytics,reading-time"

Plugin names correspond to filenames without .php:

  • languageslanguages.php
  • analyticsanalytics.php
  • reading-timereading-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:

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)
  • $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:

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:

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:

// 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

// Add reading time to template variables
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    // Only calculate if we have content
    if (isset($vars['content'])) {
        $wordCount = str_word_count(strip_tags($vars['content']));
        $wordsPerMinute = 200;
        $readingTime = max(1, round($wordCount / $wordsPerMinute));
        
        $vars['readingTime'] = $readingTime;
        $vars['wordCount'] = $wordCount;
    }
    
    return $vars;
});

Step 2: Enable the Plugin

custom/config.ini:

[plugins]
enabled = "languages,reading-time"

Step 3: Use in Template

custom/templates/page.php:

<article>
  <header>
    <h1><?= htmlspecialchars($metadata['title'] ?? 'Untitled') ?></h1>
    
    <?php if (isset($readingTime)): ?>
      <p class="reading-time">
        <?= $readingTime ?> min read (<?= number_format($wordCount) ?> words)
      </p>
    <?php endif; ?>
  </header>
  
  <div class="content">
    <?= $content ?>
  </div>
</article>

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

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    global $config;
    
    // Read tracking ID from config
    $trackingId = $config['analytics']['tracking_id'] ?? null;
    
    if ($trackingId) {
        $vars['analyticsId'] = $trackingId;
    }
    
    return $vars;
});

custom/config.ini:

[analytics]
tracking_id = "G-XXXXXXXXXX"

[plugins]
enabled = "languages,analytics"

custom/templates/base.php:

<head>
  <!-- ... -->
  
  <?php if (isset($analyticsId)): ?>
    <script async src="https://www.googletagmanager.com/gtag/js?id=<?= htmlspecialchars($analyticsId) ?>"></script>
    <script>
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());
      gtag('config', '<?= htmlspecialchars($analyticsId) ?>');
    </script>
  <?php endif; ?>
</head>

Table of Contents Plugin

Generate a table of contents from headings.

custom/plugins/global/toc.php:

<?php

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    if (!isset($vars['content'])) {
        return $vars;
    }
    
    $content = $vars['content'];
    $toc = [];
    
    // Extract headings
    if (preg_match_all('/<h([2-3])>(.*?)<\/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('<h' . $level . '>', '<h' . $level . ' id="' . $id . '">', $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 if (!empty($tableOfContents)): ?>
  <nav class="toc">
    <h2>Table of Contents</h2>
    <ul>
      <?php foreach ($tableOfContents as $item): ?>
        <li class="toc-level-<?= $item['level'] ?>">
          <a href="#<?= htmlspecialchars($item['id']) ?>">
            <?= htmlspecialchars($item['text']) ?>
          </a>
        </li>
      <?php endforeach; ?>
    </ul>
  </nav>
<?php endif; ?>

<article>
  <?= $content ?>
</article>

Author Bio Plugin

Add author information from metadata.

custom/plugins/global/author-bio.php:

<?php

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    $metadata = $ctx->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:

name = "Jane Doe"
bio = "Web developer and writer"
email = "jane@example.com"
twitter = "@janedoe"
website = "https://janedoe.com"

Use in template:

<?php if (isset($authorBio)): ?>
  <aside class="author-bio">
    <h3><?= htmlspecialchars($authorBio['name'] ?? 'Unknown') ?></h3>
    <p><?= htmlspecialchars($authorBio['bio'] ?? '') ?></p>
    
    <?php if (isset($authorBio['website'])): ?>
      <a href="<?= htmlspecialchars($authorBio['website']) ?>">Website</a>
    <?php endif; ?>
  </aside>
<?php endif; ?>

Show related posts based on tags.

custom/plugins/global/related-posts.php:

<?php

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    $metadata = $ctx->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 if (!empty($relatedPosts)): ?>
  <aside class="related-posts">
    <h3>Related Posts</h3>
    <ul>
      <?php foreach ($relatedPosts as $post): ?>
        <li>
          <a href="<?= htmlspecialchars($post['url']) ?>">
            <?= htmlspecialchars($post['title']) ?>
          </a>
        </li>
      <?php endforeach; ?>
    </ul>
  </aside>
<?php endif; ?>

Best Practices

1. Always Return Modified Data

// 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

// 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:

[reading_time]
words_per_minute = 250

3. Check Variable Existence

// 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

// Good: prefixed function name
function readingTime_calculate(string $content): int {
    // ...
}

// Bad: generic name (may conflict)
function calculate(string $content): int {
    // ...
}

5. Use Type Hints

// 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
error_log("My plugin loaded!");

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    error_log("TEMPLATE_VARS hook called");
    error_log("Variables: " . print_r(array_keys($vars), true));
    
    $vars['debug'] = 'Plugin is working';
    return $vars;
});

Check the error log:

tail -f /var/log/apache2/error.log

Inspect Hook Order

Hooks execute in the order they're registered. Check order by logging:

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    error_log("Hook 1: " . json_encode(array_keys($vars)));
    return $vars;
});

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    error_log("Hook 2: " . json_encode(array_keys($vars)));
    return $vars;
});

Dump Variables in Templates

Temporarily add to your template:

<pre><?php var_dump($customVariable); ?></pre>

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?