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
This commit is contained in:
parent
92d681feb9
commit
bb38245218
1 changed files with 494 additions and 0 deletions
494
app/docs/language-plugin.md
Normal file
494
app/docs/language-plugin.md
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
# 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)
|
||||||
Loading…
Add table
Reference in a new issue