From bb38245218c62c3b6723382b7f08f2b8711b1771 Mon Sep 17 00:00:00 2001 From: Ruben Date: Tue, 25 Nov 2025 23:29:01 +0100 Subject: [PATCH] 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 --- app/docs/language-plugin.md | 494 ++++++++++++++++++++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 app/docs/language-plugin.md diff --git a/app/docs/language-plugin.md b/app/docs/language-plugin.md new file mode 100644 index 0000000..a07492c --- /dev/null +++ b/app/docs/language-plugin.md @@ -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 Mastodon" +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 + +

Norwegian content

+ +``` + +### Using Translations + +```php + + + + +

+``` + +### Building Language Switcher + +```php + +``` + +### Language-Aware Navigation + +```php + +``` + +## 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)