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