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:
- Reads language config from
config.ini - Checks if URL starts with a language code
- If non-default language found, removes it from path
- 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:
- Language-specific files (
.lang.ext) only show for that language - Default files only show if no language version exists
- 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.inifiles$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 translationscustom/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:
- URL arrives:
/no/kontakt/ - Language extracted:
currentLang = "no", path becomeskontakt/ - Router calls
resolveSlugToFolder()to find actual folder - Plugin has merged Norwegian metadata:
slug = "kontakt" - Folder
contact/matches slugkontakt - Content from
contact/folder is served
Language switcher flow:
- User clicks language switcher on
/no/kontakt/ - Plugin resolves path to folder:
kontakt→contactfolder - Loads English metadata for
contact/folder - Finds English slug:
contact - Generates URL:
/contact/ - User navigates to correct English page
Language URLs Generation
The buildLanguageUrls() function generates URLs for all languages:
Process
- Frontpage - Simple language prefixes
- 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:
- Resolve path:
kontakt→ foldercontact - For English (
en):- Load
contact/metadata.ini - Extract English slug:
contact - Build URL:
/contact/(default lang, no prefix)
- Load
- For Norwegian (
no):- Load
contact/metadata.ini - Extract Norwegian slug:
kontakt - Build URL:
/no/kontakt/
- Load
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):
-
Update config:
[languages] default = "en" available = "en,no,fr" -
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,..." -
Add French content:
- Create
.fr.mdfiles - Add
[fr]sections tometadata.ini
- Create
-
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
Related Files
- Plugin:
app/plugins/global/languages.php - Hook system:
app/hooks.php - Content loading:
app/content.php - Routing:
app/router.php