folderweb/app/docs/language-plugin.md
Ruben bb38245218 Add language plugin documentation
Add documentation for the language plugin that provides URL-based
internationalization support with language-specific URLs, content files,
slugs, and translations
2025-11-25 23:29:01 +01:00

12 KiB

Language Plugin Reference

Overview

The language plugin provides URL-based internationalization (i18n) support for FolderWeb. It enables multi-language sites with language-specific URLs, content files, slugs, and translations.

Configuration

Basic Setup

Add language configuration to custom/config.ini:

[languages]
default = "en"           # Default language (no URL prefix)
available = "en,no,fr"   # Comma-separated list of languages

Enable the plugin:

[plugins]
enabled = "languages"

URL Structure

The plugin modifies URL behavior based on language:

  • Default language: /about/ (no prefix)
  • Other languages: /no/about/, /fr/about/

The language code is extracted from the URL and removed from the request path before routing.

How It Works

1. URL Language Extraction (CONTEXT_READY Hook)

When a request arrives, the plugin:

  1. Reads language config from config.ini
  2. Checks if URL starts with a language code
  3. If non-default language found, removes it from path
  4. Stores language data in context

Example:

URL: /no/about/contact/
↓
Language extracted: "no"
Path updated to: about/contact/
Context stores: currentLang="no", langPrefix="/no"

Implementation:

Hooks::add(Hook::CONTEXT_READY, function(Context $ctx, array $config) {
    $defaultLang = $config['languages']['default'] ?? 'en';
    $availableLangs = explode(',', $config['languages']['available'] ?? 'en');
    
    $pathParts = explode('/', $ctx->requestPath);
    $currentLang = $defaultLang;
    
    // Extract language prefix if present
    if (!empty($pathParts[0]) && in_array($pathParts[0], $availableLangs)) {
        if ($pathParts[0] !== $defaultLang) {
            $currentLang = array_shift($pathParts);
            // Update request path
            $reflection = new ReflectionProperty($ctx, 'requestPath');
            $reflection->setValue($ctx, implode('/', $pathParts));
        }
    }
    
    // Store in context
    $ctx->set('currentLang', $currentLang);
    $ctx->set('defaultLang', $defaultLang);
    $ctx->set('availableLangs', $availableLangs);
    $ctx->set('langPrefix', $currentLang !== $defaultLang ? "/$currentLang" : '');
    $ctx->set('translations', loadTranslations($currentLang));
    
    return $ctx;
});

2. Content Filtering (PROCESS_CONTENT Hook)

The plugin filters content in three ways:

A. Metadata Merging

Language-specific metadata sections are merged into base metadata:

metadata.ini:

title = "Contact"
slug = "contact"

[no]
title = "Kontakt"
slug = "kontakt"

When viewing Norwegian version, metadata becomes:

[
    'title' => 'Kontakt',
    'slug' => 'kontakt',
    '_raw' => [...original data...]
]

Implementation:

if ($extraContext === 'metadata') {
    if (isset($data['_raw'][$currentLang])) {
        return array_merge($data, $data['_raw'][$currentLang]);
    }
}

B. Content File Filtering

Files follow the naming pattern: filename.{lang}.{ext}

Example directory:

content/about/
├── index.md       # Default language
├── index.no.md    # Norwegian
├── image.jpg      # Shared asset
└── metadata.ini

Filtering rules:

  1. Language-specific files (.lang.ext) only show for that language
  2. Default files only show if no language version exists
  3. Non-content files are ignored

Implementation:

if (is_array($data) && isset($data[0]['path'])) {
    $filtered = [];
    $seen = [];
    
    foreach ($files as $file) {
        $parts = explode('.', $file['name']);
        
        // Check for language-specific file
        if (count($parts) >= 3 && in_array($parts[-2], $availableLangs)) {
            if ($parts[-2] === $currentLang) {
                $filtered[] = $file;
                $seen[$parts[0]] = true;
            }
            continue;
        }
        
        // Include default file if no language version exists
        if (!isset($seen[$parts[0]]) && !hasLanguageVersion($dir, $parts[0])) {
            $filtered[] = $file;
        }
    }
    
    return $filtered;
}

C. Date Formatting

Dates are formatted with localized month names from translation files:

Implementation:

if ($dirOrType === 'date_format') {
    return formatDate($data, $currentLang);
}

3. Template Variables (TEMPLATE_VARS Hook)

The plugin adds language-related variables to all templates:

Variables added:

  • $currentLang - Active language code (e.g., "no")
  • $defaultLang - Default language code from config
  • $langPrefix - URL prefix for current language (e.g., "/no" or "")
  • $translations - Array of translated strings from .ini files
  • $availableLangs - Array of all configured languages
  • $languageUrls - Pre-built URLs for all languages with correct slugs

Implementation:

Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
    $vars['currentLang'] = $ctx->get('currentLang');
    $vars['defaultLang'] = $ctx->get('defaultLang');
    $vars['langPrefix'] = $ctx->get('langPrefix');
    $vars['translations'] = $ctx->get('translations');
    $vars['availableLangs'] = $ctx->get('availableLangs');
    $vars['languageUrls'] = buildLanguageUrls($ctx, ...);
    return $vars;
});

Translation Files

Translation files are INI files located in:

  • app/default/languages/{lang}.ini - Default translations
  • custom/languages/{lang}.ini - Custom translations (override defaults)

Format

; English translations (en.ini)

; Language names for language switcher
language_name = "English"
language_name_no = "Norsk"
language_name_fr = "Français"

; Navigation
home = "Home"
categories = "Categories"
tags = "Tags"

; Footer
footer_social = "Follow us on <a href='...'>Mastodon</a>"
footer_handcoded = "This website was hand-coded by..."
footer_page_time = "ms to generate this page."

; Months for date formatting
months = "January,February,March,April,May,June,July,August,September,October,November,December"

Loading Logic

function loadTranslations(string $lang): array {
    $defaultFile = "app/default/languages/$lang.ini";
    $customFile = "custom/languages/$lang.ini";
    
    $translations = file_exists($defaultFile) 
        ? parse_ini_file($defaultFile) 
        : [];
    
    if (file_exists($customFile)) {
        $translations = array_merge($translations, parse_ini_file($customFile));
    }
    
    return $translations;
}

Language-Specific Slugs

Slugs can be translated in metadata:

Routing flow:

  1. URL arrives: /no/kontakt/
  2. Language extracted: currentLang = "no", path becomes kontakt/
  3. Router calls resolveSlugToFolder() to find actual folder
  4. Plugin has merged Norwegian metadata: slug = "kontakt"
  5. Folder contact/ matches slug kontakt
  6. Content from contact/ folder is served

Language switcher flow:

  1. User clicks language switcher on /no/kontakt/
  2. Plugin resolves path to folder: kontaktcontact folder
  3. Loads English metadata for contact/ folder
  4. Finds English slug: contact
  5. Generates URL: /contact/
  6. User navigates to correct English page

Language URLs Generation

The buildLanguageUrls() function generates URLs for all languages:

Process

  1. Frontpage - Simple language prefixes
  2. Regular pages:
    • Resolve current path to actual folder names
    • For each language, load metadata for each folder
    • Extract language-specific slug from metadata
    • Build complete path with language prefix

Example

Current URL: /no/kontakt/

Process:

  1. Resolve path: kontakt → folder contact
  2. For English (en):
    • Load contact/metadata.ini
    • Extract English slug: contact
    • Build URL: /contact/ (default lang, no prefix)
  3. For Norwegian (no):
    • Load contact/metadata.ini
    • Extract Norwegian slug: kontakt
    • Build URL: /no/kontakt/

Result:

$languageUrls = [
    'en' => '/contact/',
    'no' => '/no/kontakt/'
];

Helper Functions

loadTranslations()

Loads translation files for a specific language.

function loadTranslations(string $lang): array

Returns: Array of translation key-value pairs

formatDate()

Formats ISO date strings with localized month names.

function formatDate(string $dateString, string $lang): string

Input: "2025-11-25"
Output (Norwegian): "25. november 2025"

filterFilesByLanguage()

Filters content files based on current language.

function filterFilesByLanguage(array $files, string $dir, Context $ctx): array

buildLanguageUrls()

Generates URLs for all languages with correct slugs.

function buildLanguageUrls(Context $ctx, string $currentLang, string $defaultLang, array $availableLangs): array

Returns: ['en' => '/about/', 'no' => '/no/om/', ...]

resolvePath()

Resolves URL path to actual folder names.

function resolvePath(string $path, string $contentDir): ?array

Input: "kontakt"
Returns: ["contact"] (actual folder name)

Template Usage

Accessing Current Language

<?php if ($currentLang === 'no'): ?>
    <p>Norwegian content</p>
<?php endif; ?>

Using Translations

<a href="<?= $langPrefix ?>/">
    <?= htmlspecialchars($translations['home'] ?? 'Home') ?>
</a>

<p><?= $translations['footer_text'] ?></p>

Building Language Switcher

<nav class="language-switcher">
    <?php foreach ($availableLangs as $lang): ?>
        <a href="<?= htmlspecialchars($languageUrls[$lang] ?? '/') ?>" 
           hreflang="<?= $lang ?>"
           <?= $lang === $currentLang ? 'aria-current="page"' : '' ?>>
            <?= htmlspecialchars($translations["language_name_$lang"] ?? strtoupper($lang)) ?>
        </a>
    <?php endforeach; ?>
</nav>

Language-Aware Navigation

<nav>
    <?php foreach ($navigation as $item): ?>
        <a href="<?= $langPrefix ?><?= htmlspecialchars($item['url']) ?>">
            <?= htmlspecialchars($item['title']) ?>
        </a>
    <?php endforeach; ?>
</nav>

Adding a New Language

To add a new language (e.g., French):

  1. Update config:

    [languages]
    default = "en"
    available = "en,no,fr"
    
  2. Create translation file (custom/languages/fr.ini):

    language_name = "Français"
    language_name_en = "English"
    language_name_no = "Norsk"
    home = "Accueil"
    months = "janvier,février,mars,..."
    
  3. Add French content:

    • Create .fr.md files
    • Add [fr] sections to metadata.ini
  4. No code changes needed - Plugin automatically handles it

Content Organization Patterns

Single File Per Language

content/about/
├── index.md       # English
├── index.no.md    # Norwegian
├── index.fr.md    # French
└── metadata.ini   # Translated metadata

Separate Directories

content/blog/
├── 2025-11-01-english-post/
│   └── index.md
├── 2025-11-01-norwegian-post/
│   └── index.no.md
└── 2025-11-01-french-post/
    └── index.fr.md

Mixed Strategy

content/
├── about/
│   ├── index.md       # Translated
│   ├── index.no.md
│   └── metadata.ini
└── blog/
    └── 2025-11-01-news/
        └── index.md   # English only

Performance Considerations

  • Translation files loaded once per request
  • Metadata parsed once per directory access
  • Content filtering happens during directory scan
  • URL generation cached in template variables

Limitations

  • No automatic content translation
  • Language must be first path segment (after domain)
  • Default language never has URL prefix
  • Shared assets must be referenced relatively
  • Plugin: app/plugins/global/languages.php
  • Hook system: app/hooks.php
  • Content loading: app/content.php
  • Routing: app/router.php

See Also