folderweb/app/docs/language-plugin.md

495 lines
12 KiB
Markdown
Raw Normal View History

# 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`:
```ini
[languages]
default = "en" # Default language (no URL prefix)
available = "en,no,fr" # Comma-separated list of languages
```
Enable the plugin:
```ini
[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:**
```php
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:**
```ini
title = "Contact"
slug = "contact"
[no]
title = "Kontakt"
slug = "kontakt"
```
When viewing Norwegian version, metadata becomes:
```php
[
'title' => 'Kontakt',
'slug' => 'kontakt',
'_raw' => [...original data...]
]
```
**Implementation:**
```php
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:**
```php
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:**
```php
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:**
```php
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
```ini
; 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
```php
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: `kontakt``contact` 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:**
```php
$languageUrls = [
'en' => '/contact/',
'no' => '/no/kontakt/'
];
```
## Helper Functions
### loadTranslations()
Loads translation files for a specific language.
```php
function loadTranslations(string $lang): array
```
**Returns:** Array of translation key-value pairs
### formatDate()
Formats ISO date strings with localized month names.
```php
function formatDate(string $dateString, string $lang): string
```
**Input:** `"2025-11-25"`
**Output (Norwegian):** `"25. november 2025"`
### filterFilesByLanguage()
Filters content files based on current language.
```php
function filterFilesByLanguage(array $files, string $dir, Context $ctx): array
```
### buildLanguageUrls()
Generates URLs for all languages with correct slugs.
```php
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.
```php
function resolvePath(string $path, string $contentDir): ?array
```
**Input:** `"kontakt"`
**Returns:** `["contact"]` (actual folder name)
## Template Usage
### Accessing Current Language
```php
<?php if ($currentLang === 'no'): ?>
<p>Norwegian content</p>
<?php endif; ?>
```
### Using Translations
```php
<a href="<?= $langPrefix ?>/">
<?= htmlspecialchars($translations['home'] ?? 'Home') ?>
</a>
<p><?= $translations['footer_text'] ?></p>
```
### Building Language Switcher
```php
<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
```php
<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:**
```ini
[languages]
default = "en"
available = "en,no,fr"
```
2. **Create translation file** (`custom/languages/fr.ini`):
```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
## Related Files
- Plugin: `app/plugins/global/languages.php`
- Hook system: `app/hooks.php`
- Content loading: `app/content.php`
- Routing: `app/router.php`
## See Also
- [Plugin System Reference](plugin-system.md)
- [How to Create Multi-Language Sites](../../docs/how-to/multi-language.md)
- [Configuration Reference](../../docs/reference/configuration.md)