Files
2026-02-18 01:31:41 +01:00

497 lines
21 KiB
Markdown

# Phase 15: Locale Detection & Routing - Research
**Researched:** 2026-02-02
**Domain:** WinterCMS locale routing, URL prefix patterns, browser detection, preference persistence
**Confidence:** HIGH
## Summary
This phase extends the existing Golem15\Translate plugin infrastructure to implement URL-based locale routing with automatic browser detection and persistent preference storage. The core infrastructure already exists and is well-implemented - LocaleMiddleware handles the priority cascade (URL > User DB > Session > Browser > Default), Translator manages locale state, and LocalePicker/AlternateHrefLangElements components are available.
The primary work involves: (1) Setting the `locale_manually_set` cookie when switching locales via URL prefix or LocalePicker, (2) Creating a language suggestion banner component for mismatched browser languages, (3) Adding a language preference section to the account settings page, and (4) Integrating AlternateHrefLangElements into all layouts for SEO.
**Primary recommendation:** Leverage existing plugin infrastructure completely. Focus on UI integration (language switcher in header, suggestion banner, account settings), cookie management enhancement, and ensuring logged-in users' preferences sync to DB.
## Standard Stack
The established libraries/tools for this domain:
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Golem15\Translate | Current | Full translation infrastructure | Already in codebase, handles locales, middleware, components |
| Golem15\User | Current | User model with `preferred_locale` field | Already has preference field, integrated with middleware |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| LocaleMiddleware | Current | Priority cascade for locale detection | Already registered in CmsController |
| Translator | Current | Singleton for locale state management | Use for all locale operations |
| LocalePicker | Current | Component for language switching | Embed in header |
| AlternateHrefLangElements | Current | Generate hreflang tags | Add to all layouts |
### Already Configured
The following is already configured in `/config/golem15/translate/config.php`:
- `prefixDefaultLocale: false` - English (default) has no URL prefix
- `disableLocalePrefixRoutes: false` - URL prefixes enabled
- `browserDetection.enabled: true` - Browser detection active
- `browserDetection.manualSelectionCookie: 'locale_manually_set'` - Cookie name defined
**No additional package installation required.**
## Architecture Patterns
### Existing Infrastructure Overview
```
plugins/golem15/translate/
|-- classes/
| |-- LocaleMiddleware.php # Priority cascade: URL > User > Session > Browser > Default
| |-- Translator.php # Singleton, setLocale/getLocale/loadLocaleFromRequest
|-- components/
| |-- LocalePicker.php # onSwitchLocale handler, forceUrl property
| |-- AlternateHrefLangElements.php # Generates hreflang tags
|-- config/config.php # browserDetection settings
|-- routes.php # Registers /{locale}/* route groups
```
### Pattern 1: Locale Priority Cascade (Existing)
**What:** The middleware implements a clear priority order for locale resolution.
**When to use:** Already runs on every CMS request.
**Implementation (from LocaleMiddleware.php):**
```php
// Priority 1: URL prefix (explicit override)
if (!$translator->loadLocaleFromRequest()) {
// Priority 2: Authenticated user's preferred_locale from DB
if (!$this->loadLocaleFromUser($translator)) {
// Priority 3: Session (previous selection)
$localeLoaded = $translator->loadLocaleFromSession();
// Priority 4: Browser Accept-Language (only without manual selection cookie)
if (!$localeLoaded && !$this->hasManualLocaleSelection($request)) {
$localeLoaded = $this->loadLocaleFromBrowser($translator, $request);
}
// Priority 5: Default locale fallback
if (!$localeLoaded) {
$translator->setLocale($translator->getDefaultLocale());
}
}
}
```
### Pattern 2: URL Prefix Routing (Existing)
**What:** Routes with locale prefix are registered dynamically.
**When to use:** Automatically for any URL with `/pl/` or `/de/` prefix.
**Implementation (from routes.php):**
```php
$locale = $translator->getLocale();
Route::group(['prefix' => $locale, 'middleware' => 'web'], function () {
Route::any('{slug?}', 'Cms\Classes\CmsController@run')->where('slug', '(.*)?');
});
```
### Pattern 3: LocalePicker Component Usage
**What:** Provides locale switching with proper URL handling.
**When to use:** In theme partials for language switcher.
**Example:**
```twig
{# In layout or header partial #}
{% component 'localePicker' %}
{# Custom implementation #}
<div class="language-switcher">
<button class="language-current">{{ activeLocaleName }}</button>
<ul class="language-list">
{% for code, name in locales %}
<li>
<a href="#"
data-request="localePicker::onSwitchLocale"
data-request-data="locale: '{{ code }}'">
{{ name }}
</a>
</li>
{% endfor %}
</ul>
</div>
```
### Pattern 4: Manual Selection Cookie
**What:** Cookie that prevents browser auto-detection after manual selection.
**Gap Found:** Cookie is READ by LocaleMiddleware but NOT SET anywhere.
**Required Enhancement:**
```php
// In LocalePicker::onSwitchLocale() - set cookie
Cookie::queue(
Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600) // 1 year
);
// In routes.php - set cookie when URL prefix detected
if ($translator->loadLocaleFromRequest()) {
Cookie::queue('locale_manually_set', '1', 525600);
}
```
### Pattern 5: Hreflang Implementation
**What:** Automatic generation of alternate language links for SEO.
**When to use:** In all layouts' `<head>` section.
**Example:**
```twig
{# In layout head #}
{% component 'alternateHrefLangElements' %}
{# Generates #}
<link rel="alternate" hreflang="en" href="https://quotify.pro/jobs">
<link rel="alternate" hreflang="pl" href="https://quotify.pro/pl/jobs">
<link rel="alternate" hreflang="de" href="https://quotify.pro/de/jobs">
```
### Anti-Patterns to Avoid
- **Auto-redirect without user consent:** Never silently redirect users based on browser language. Use suggestion banners instead.
- **Coupling locale with location/currency:** Locale is language preference only. Don't assume location from language.
- **Modal popups for language selection:** Use non-modal banners - users instinctively dismiss modals.
- **Overwriting DB preference on URL prefix visit:** URL prefix should only affect the current session, not update the user's stored preference.
## Don't Hand-Roll
Problems that look simple but have existing solutions:
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Locale detection from URL | Custom URL parser | `Translator::loadLocaleFromRequest()` | Handles edge cases, segment extraction |
| Accept-Language parsing | Custom header parser | `LocaleMiddleware::parseAcceptLanguage()` | Handles quality values, malformed headers |
| Locale validation | Custom list check | `Locale::isValid($locale)` | Uses cached locale list |
| Session persistence | Custom session handling | `Translator::setLocale($locale, true)` | Second param enables session storage |
| Hreflang generation | Custom link builder | `AlternateHrefLangElements` component | Handles static pages, CMS pages, URL params |
**Key insight:** The Golem15\Translate plugin has comprehensive infrastructure. The only gaps are: (1) cookie setting on manual selection, (2) UI components for the theme, (3) user preference sync to DB.
## Common Pitfalls
### Pitfall 1: Cookie Not Being Set on Manual Selection
**What goes wrong:** Users who switch language via picker or URL prefix still get browser detection on next visit.
**Why it happens:** LocaleMiddleware reads `locale_manually_set` cookie but it's never written.
**How to avoid:** Add cookie setting in LocalePicker::onSwitchLocale() and routes.php when URL prefix is detected.
**Warning signs:** Browser detection triggers even after user has explicitly chosen a language.
### Pitfall 2: User Preference Not Syncing to Database
**What goes wrong:** Logged-in users switch language but it's not saved to their profile.
**Why it happens:** LocalePicker only sets session, doesn't update `User::preferred_locale`.
**How to avoid:** Extend onSwitchLocale to update `Auth::getUser()->preferred_locale` if authenticated.
**Warning signs:** User logs in on another device and sees wrong language.
### Pitfall 3: Hreflang Without x-default
**What goes wrong:** Search engines don't know which URL to use for unmatched languages.
**Why it happens:** Missing x-default hreflang tag.
**How to avoid:** Ensure AlternateHrefLangElements or custom implementation includes `<link rel="alternate" hreflang="x-default">` pointing to default locale URL.
**Warning signs:** Search Console shows hreflang warnings.
### Pitfall 4: Modal Popup for Language Suggestion
**What goes wrong:** Users instinctively close modals without reading.
**Why it happens:** Using modal instead of dismissable banner.
**How to avoid:** Use sticky top/bottom banner that doesn't block interaction.
**Warning signs:** High dismissal rates, low language switch conversions.
### Pitfall 5: Snowboard data-request-success
**What goes wrong:** Callbacks fail silently.
**Why it happens:** WinterCMS Snowboard framework removed `data-request-success` as unsafe (uses eval).
**How to avoid:** Use `Snowboard.request()` with `success` callback function.
**Warning signs:** No callback execution after AJAX request.
## Code Examples
Verified patterns from existing codebase:
### Language Switcher Dropdown (Theme Implementation)
```twig
{# themes/quotify/partials/language-switcher.htm #}
{% set locales = localePicker.locales %}
{% set activeLocale = localePicker.activeLocale %}
{% set activeLocaleName = localePicker.activeLocaleName %}
<div class="language-switcher" data-language-switcher>
<button type="button" class="language-switcher-toggle" aria-expanded="false" aria-haspopup="true">
<span class="language-switcher-label">{{ activeLocaleName }}</span>
<svg class="language-switcher-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
</svg>
</button>
<ul class="language-switcher-list" role="menu">
{% for code, name in locales %}
<li role="none">
<a href="#" role="menuitem"
class="language-switcher-option {% if code == activeLocale %}active{% endif %}"
data-request="localePicker::onSwitchLocale"
data-request-data="locale: '{{ code }}'">
{{ name }}
</a>
</li>
{% endfor %}
</ul>
</div>
```
### Language Suggestion Banner Component
```php
// plugins/golem15/translate/components/LocaleSuggestionBanner.php
namespace Golem15\Translate\Components;
use Cms\Classes\ComponentBase;
use Golem15\Translate\Classes\Translator;
use Golem15\Translate\Models\Locale;
use Request;
use Config;
class LocaleSuggestionBanner extends ComponentBase
{
public function componentDetails(): array
{
return [
'name' => 'Locale Suggestion Banner',
'description' => 'Shows banner when browser language differs from current page'
];
}
public function onRun()
{
$this->page['showBanner'] = $this->shouldShowBanner();
$this->page['suggestedLocale'] = $this->getSuggestedLocale();
$this->page['suggestedLocaleName'] = $this->getSuggestedLocaleName();
}
protected function shouldShowBanner(): bool
{
// Don't show if manually set
if (Request::cookie('locale_manually_set')) {
return false;
}
// Don't show if dismissed this session
if (session('locale_banner_dismissed')) {
return false;
}
// Don't show if 1-week dismissal cookie exists
if (Request::cookie('locale_banner_dismissed')) {
return false;
}
// Check if browser language differs from current
return $this->getSuggestedLocale() !== null;
}
protected function getSuggestedLocale(): ?string
{
$browserLocale = $this->detectBrowserLocale();
$currentLocale = Translator::instance()->getLocale();
if ($browserLocale && $browserLocale !== $currentLocale) {
return $browserLocale;
}
return null;
}
protected function detectBrowserLocale(): ?string
{
$acceptLanguage = Request::header('Accept-Language');
if (!$acceptLanguage) return null;
$enabledLocales = array_keys(Locale::listEnabled());
$candidates = explode(',', $acceptLanguage);
foreach ($candidates as $candidate) {
$code = strtolower(substr(trim(explode(';', $candidate)[0]), 0, 2));
if (in_array($code, $enabledLocales)) {
return $code;
}
}
return null;
}
protected function getSuggestedLocaleName(): ?string
{
$locale = $this->getSuggestedLocale();
if (!$locale) return null;
$locales = Locale::listEnabled();
return $locales[$locale] ?? null;
}
public function onDismissBanner()
{
session(['locale_banner_dismissed' => true]);
// Set 1-week dismissal cookie
\Cookie::queue('locale_banner_dismissed', '1', 60 * 24 * 7); // 7 days
return ['success' => true];
}
public function onSwitchToSuggested()
{
$locale = post('locale');
if (!Locale::isValid($locale)) {
return;
}
Translator::instance()->setLocale($locale, true);
// Set manual selection cookie
\Cookie::queue(
Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600)
);
return \Redirect::refresh();
}
}
```
### Banner Partial Template
```twig
{# components/localesuggestionbanner/default.htm #}
{% if showBanner and suggestedLocale %}
<div class="locale-suggestion-banner" data-locale-banner role="alert">
<div class="container">
<div class="locale-suggestion-content">
<p class="locale-suggestion-text">
{% if suggestedLocale == 'pl' %}
{{ 'Ta strona jest dostepna po polsku'|_ }}
{% elseif suggestedLocale == 'de' %}
{{ 'Diese Seite ist auf Deutsch verfügbar'|_ }}
{% else %}
{{ 'This page is available in'|_ }} {{ suggestedLocaleName }}
{% endif %}
</p>
<div class="locale-suggestion-actions">
<button type="button"
class="btn btn-sm btn-primary"
data-request="{{ __SELF__ }}::onSwitchToSuggested"
data-request-data="locale: '{{ suggestedLocale }}'">
{% if suggestedLocale == 'pl' %}Przełącz na polski{% elseif suggestedLocale == 'de' %}Zu Deutsch wechseln{% else %}Switch{% endif %}
</button>
<button type="button"
class="btn btn-sm btn-link"
data-request="{{ __SELF__ }}::onDismissBanner"
data-request-update="{ '{{ __SELF__ }}': '@self' }">
<svg class="icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
{% endif %}
```
### Syncing Preference to User Database
```php
// Extend LocalePicker::onSwitchLocale in Plugin.php boot()
\Golem15\Translate\Components\LocalePicker::extend(function($component) {
$component->bindEvent('component.beforeRunAjaxHandler', function($handler) use ($component) {
if ($handler === 'onSwitchLocale') {
$locale = post('locale');
// Set manual selection cookie
\Cookie::queue(
\Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
\Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600)
);
// Update user preference in DB if logged in
if (\Auth::check()) {
$user = \Auth::getUser();
$user->preferred_locale = $locale;
$user->save();
}
}
});
});
```
### Account Settings Language Preference Section
```twig
{# Add to account/update.htm - new section #}
<div class="account-section">
<div class="account-section-header">
<h3 class="account-section-title">{{ 'Language Preference'|_ }}</h3>
<p class="account-section-description">{{ 'Choose your preferred language for the site.'|_ }}</p>
</div>
<div class="account-section-content">
<div class="form-group">
<label for="preferredLocale" class="form-label">{{ 'Preferred Language'|_ }}</label>
<select name="preferred_locale" id="preferredLocale" class="form-control">
{% set locales = {'en': 'English', 'pl': 'Polski', 'de': 'Deutsch'} %}
{% for code, name in locales %}
<option value="{{ code }}" {% if user.preferred_locale == code %}selected{% endif %}>
{{ name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Modal popups for language | Non-modal sticky banners | 2022+ | Better UX, lower dismissal rates |
| Auto-redirect on language detection | Suggestion banner + user choice | Best practice | SEO-safe, respects user intent |
| Coupling language/region/currency | Decoupled preferences | Industry standard | Users can mix preferences |
**Deprecated/outdated:**
- `data-request-success` attribute: Removed from WinterCMS Snowboard as unsafe (eval). Use `Snowboard.request()` with callback.
## Open Questions
Things that couldn't be fully resolved:
1. **x-default hreflang tag**
- What we know: AlternateHrefLangElements generates per-locale links
- What's unclear: Whether it includes x-default automatically
- Recommendation: Verify in testing, add manually if missing
2. **URL prefix cookie setting location**
- What we know: Cookie should be set when URL has locale prefix
- What's unclear: Best place to intercept (routes.php vs middleware)
- Recommendation: Test in routes.php first; middleware as fallback
## Sources
### Primary (HIGH confidence)
- `/plugins/golem15/translate/classes/LocaleMiddleware.php` - Priority cascade implementation
- `/plugins/golem15/translate/classes/Translator.php` - Locale state management
- `/plugins/golem15/translate/components/LocalePicker.php` - Component implementation
- `/plugins/golem15/translate/components/AlternateHrefLangElements.php` - Hreflang generation
- `/plugins/golem15/translate/config/config.php` - Default configuration
- `/config/golem15/translate/config.php` - Project-specific config
- `/plugins/golem15/user/models/User.php` - preferred_locale field
### Secondary (MEDIUM confidence)
- [Smashing Magazine: Designing Better Language Selector](https://www.smashingmagazine.com/2022/05/designing-better-language-selector/) - UX patterns for language selectors
- [Google Search Central: Localized Versions](https://developers.google.com/search/docs/specialty/international/localized-versions) - Hreflang best practices
- [TranslatePress: Automatic User Language Detection](https://translatepress.com/docs/addons/automatic-user-language-detection/) - Pop-up vs redirect patterns
### Tertiary (LOW confidence)
- WebSearch results on cookie banner patterns - General UX guidance
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - Direct inspection of existing codebase
- Architecture: HIGH - Verified from actual plugin implementation
- Pitfalls: HIGH - Identified from code gaps and CLAUDE.md constraints
- Code examples: MEDIUM - Based on existing patterns, needs testing
**Research date:** 2026-02-02
**Valid until:** 2026-03-02 (30 days - stable infrastructure)