281 lines
11 KiB
Markdown
281 lines
11 KiB
Markdown
# Multilingual Support Design
|
|||
|
|
|
||
|
|
## Context
|
||
|
|
The site needs to support both German and English content with seamless language switching. Hugo provides built-in multilingual support that we'll leverage. The current content is in German only, stored directly in `/content/`.
|
||
|
|
|
||
|
|
## Goals / Non-Goals
|
||
|
|
|
||
|
|
### Goals
|
||
|
|
- Support German (de) and English (en) languages
|
||
|
|
- Automatic browser language detection for first-time visitors
|
||
|
|
- Persistent language preference via URL structure
|
||
|
|
- Language switcher that maintains page context (e.g., `/de/cv` ↔ `/en/cv`)
|
||
|
|
- All existing content translated to English
|
||
|
|
- Clean URL structure using language codes as path prefixes
|
||
|
|
|
||
|
|
### Non-Goals
|
||
|
|
- Translation management system or CMS integration
|
||
|
|
- More than 2 languages at this stage
|
||
|
|
- Client-side JavaScript for language switching (Hugo server-side only)
|
||
|
|
- Automated translation (manual translation required)
|
||
|
|
|
||
|
|
## Decisions
|
||
|
|
|
||
|
|
### Decision 1: Hugo Multilingual Mode
|
||
|
|
**Approach**: Use Hugo's built-in multilingual mode with language code prefixes in URLs
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
- Hugo natively supports multilingual sites with `defaultContentLanguage` and `[languages]` config
|
||
|
|
- URL structure `/de/` and `/en/` clearly indicates language and persists preference
|
||
|
|
- No cookies or JavaScript needed for language persistence
|
||
|
|
- SEO-friendly with proper `hreflang` tags
|
||
|
|
|
||
|
|
**Alternatives Considered**:
|
||
|
|
- Subdomain approach (de.markusgraf.ch, en.markusgraf.ch): Rejected due to complexity and DNS configuration
|
||
|
|
- Client-side JavaScript switching: Rejected to keep site static and performant
|
||
|
|
|
||
|
|
### Decision 2: Content Organization
|
||
|
|
**Approach**: Reorganize content into language-specific directories `/content/de/` and `/content/en/`
|
||
|
|
|
||
|
|
**Structure**:
|
||
|
|
```
|
||
|
|
content/
|
||
|
|
├── de/
|
||
|
|
│ ├── _index.md
|
||
|
|
│ └── cv.md
|
||
|
|
└── en/
|
||
|
|
├── _index.md
|
||
|
|
└── cv.md
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
- Clear separation of language-specific content
|
||
|
|
- Hugo automatically links translations when file paths match
|
||
|
|
- Easy to maintain and scale
|
||
|
|
|
||
|
|
### Decision 3: Language Switcher Implementation
|
||
|
|
**Approach**: Add language switcher in navigation partial using Hugo's `.Translations` function
|
||
|
|
|
||
|
|
**Implementation**:
|
||
|
|
- Display language switcher as links (e.g., "DE | EN")
|
||
|
|
- Active language styled differently
|
||
|
|
- Maintains current page context when switching
|
||
|
|
- Falls back to home page if translation doesn't exist
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
- Simple, no JavaScript required
|
||
|
|
- Hugo provides `.Translations` to find corresponding pages
|
||
|
|
- Consistent with existing navigation patterns
|
||
|
|
|
||
|
|
### Decision 4: Default Language
|
||
|
|
**Approach**: German (de) as default with English (en) as secondary
|
||
|
|
|
||
|
|
**Configuration**:
|
||
|
|
```toml
|
||
|
|
defaultContentLanguage = "de"
|
||
|
|
defaultContentLanguageInSubdir = true
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
- Existing content is in German
|
||
|
|
- Primary audience is German-speaking
|
||
|
|
- `defaultContentLanguageInSubdir = true` ensures consistent URLs (`/de/` for German, not just `/`)
|
||
|
|
|
||
|
|
### Decision 5: Browser Language Detection
|
||
|
|
**Approach**: Hugo serves content based on URL path; Apache/Nginx handles initial redirect based on `Accept-Language` header
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
- Hugo is a static site generator and cannot detect browser language at runtime
|
||
|
|
- Server-side redirect is the cleanest approach
|
||
|
|
- Falls back to default language (German) if header not present
|
||
|
|
- Once user navigates, URL path maintains their choice
|
||
|
|
|
||
|
|
**Migration Path**:
|
||
|
|
1. Configure multilingual mode in hugo.toml
|
||
|
|
2. Reorganize existing content to `/content/de/`
|
||
|
|
3. Create English translations in `/content/en/`
|
||
|
|
4. Update templates with language switcher
|
||
|
|
5. Configure server redirect rules (optional, for initial visit only)
|
||
|
|
|
||
|
|
## Risks / Trade-offs
|
||
|
|
|
||
|
|
### Risk 1: Breaking Change in Content Structure
|
||
|
|
**Risk**: Moving content files breaks existing local references and workflows
|
||
|
|
**Mitigation**:
|
||
|
|
- Clear documentation of new structure
|
||
|
|
- All content moved in single commit
|
||
|
|
- Update paths in OpenSpec documentation
|
||
|
|
|
||
|
|
### Risk 2: Translation Maintenance
|
||
|
|
**Risk**: Keeping German and English content in sync over time
|
||
|
|
**Mitigation**:
|
||
|
|
- Document translation process
|
||
|
|
- Consider adding translation status tracking in future
|
||
|
|
- For now, manual process with clear ownership
|
||
|
|
|
||
|
|
### Risk 3: SEO Impact
|
||
|
|
**Risk**: URL structure changes may affect search rankings
|
||
|
|
**Mitigation**:
|
||
|
|
- Implement proper `hreflang` tags (Hugo does this automatically)
|
||
|
|
- Add redirects from old URLs to new German URLs
|
||
|
|
- Submit new sitemap to search engines
|
||
|
|
|
||
|
|
## Migration Plan
|
||
|
|
|
||
|
|
### Phase 1: Configure Hugo (No Breaking Changes Yet)
|
||
|
|
1. Update hugo.toml with multilingual configuration
|
||
|
|
2. Test configuration with existing content structure
|
||
|
|
|
||
|
|
### Phase 2: Restructure Content
|
||
|
|
1. Move existing content to `/content/de/`
|
||
|
|
2. Update any internal references
|
||
|
|
3. Build and verify German site works at `/de/`
|
||
|
|
|
||
|
|
### Phase 3: Add English Content
|
||
|
|
1. Create `/content/en/` directory
|
||
|
|
2. Translate home page content
|
||
|
|
3. Translate CV page content
|
||
|
|
4. Translate navigation and UI strings
|
||
|
|
|
||
|
|
### Phase 4: Update Templates
|
||
|
|
1. Add language switcher to navigation
|
||
|
|
2. Update partials for multilingual strings
|
||
|
|
3. Test language switching functionality
|
||
|
|
|
||
|
|
### Phase 5: Server Configuration (Optional)
|
||
|
|
1. Add .htaccess or nginx rules for browser language detection
|
||
|
|
2. Redirect root `/` to `/de/` or `/en/` based on Accept-Language header
|
||
|
|
|
||
|
|
## Open Questions
|
||
|
|
|
||
|
|
**Q: Should the root URL `/` redirect to a language version, or show a language selection page?**
|
||
|
|
A: Redirect to `/de/` as default, optionally detect browser language. No standalone language selection page needed.
|
||
|
|
|
||
|
|
**Q: How should we handle the menu configuration for different languages?**
|
||
|
|
A: Hugo supports language-specific menus using `[[languages.de.menu.main]]` and `[[languages.en.menu.main]]` in hugo.toml.
|
||
|
|
|
||
|
|
**Q: Should we translate the project descriptions on the home page?**
|
||
|
|
A: Yes, translate all visible content including project descriptions.
|
||
|
|
|
||
|
|
## Implementation Bugs and Solutions
|
||
|
|
|
||
|
|
During implementation, several critical bugs were discovered and fixed. These are documented here to avoid future pitfalls.
|
||
|
|
|
||
|
|
### Bug 1: Language Switcher Showing Current Language
|
||
|
|
|
||
|
|
**Problem**: The language switcher initially showed a dropdown with the current language as the toggle button (e.g., showing "Deutsch" when on German pages). This was confusing because users couldn't easily see which language to switch to.
|
||
|
|
|
||
|
|
**Solution**: Simplified the language switcher to only show the alternative language as a direct link. Removed the dropdown pattern and changed from:
|
||
|
|
```html
|
||
|
|
<li class="nav-item dropdown">
|
||
|
|
<a class="dropdown-toggle">{{ .Language.LanguageName }}</a>
|
||
|
|
<ul class="dropdown-menu">...</ul>
|
||
|
|
</li>
|
||
|
|
```
|
||
|
|
To:
|
||
|
|
```html
|
||
|
|
{{ if .IsTranslated }}
|
||
|
|
{{ range .Translations }}
|
||
|
|
<li class="nav-item">
|
||
|
|
<a class="nav-link" href="{{ .RelPermalink }}">{{ .Language.LanguageName }}</a>
|
||
|
|
</li>
|
||
|
|
{{ end }}
|
||
|
|
{{ end }}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Lesson**: For a two-language site, showing only the alternative language is clearer than a dropdown showing the current language.
|
||
|
|
|
||
|
|
### Bug 2: Language Switcher Not Maintaining Page Context
|
||
|
|
|
||
|
|
**Problem**: When switching languages from `/de/cv/` to English, it would redirect to `/en/` (home) instead of `/en/cv/` (maintaining the CV page context).
|
||
|
|
|
||
|
|
**Solution**: Added `translationKey` to the frontmatter of both language versions of each content file to explicitly link them as translations:
|
||
|
|
```yaml
|
||
|
|
---
|
||
|
|
title: "Curriculum Vitae"
|
||
|
|
translationKey: "cv"
|
||
|
|
---
|
||
|
|
```
|
||
|
|
|
||
|
|
This allows Hugo's `.IsTranslated` and `.Translations` functions to correctly find corresponding pages.
|
||
|
|
|
||
|
|
**Lesson**: Hugo doesn't automatically recognize translations by filename alone when using language subdirectories. Always add `translationKey` to explicitly link translated pages.
|
||
|
|
|
||
|
|
### Bug 3: CV Pages Rendering with Wrong Language Context
|
||
|
|
|
||
|
|
**Problem**: The most critical bug - CV pages would render with the wrong language:
|
||
|
|
- `/en/cv/` would show `lang="de"`, German menu ("Lebenslauf"), and German language context
|
||
|
|
- Both German and English CV pages would show incorrect language switcher labels
|
||
|
|
|
||
|
|
**Root Cause**: Using language subdirectories (`content/de/cv.md` and `content/en/cv.md`) caused Hugo to incorrectly resolve the language context for these pages, even though `hugo list all` showed them with the correct language tags.
|
||
|
|
|
||
|
|
**Solution**: Reorganized content files to use Hugo's language suffix naming convention instead of subdirectories:
|
||
|
|
- Changed from: `content/de/cv.md` and `content/en/cv.md`
|
||
|
|
- Changed to: `content/cv.de.md` and `content/cv.en.md`
|
||
|
|
|
||
|
|
After this change, all pages rendered with the correct language context.
|
||
|
|
|
||
|
|
**Lesson**: With Hugo's multilingual mode and `defaultContentLanguageInSubdir = true`, use the `.{lang}.md` suffix pattern (e.g., `filename.de.md`, `filename.en.md`) rather than language subdirectories (`de/filename.md`, `en/filename.md`). The subdirectory approach can cause template rendering issues where the wrong language context is used.
|
||
|
|
|
||
|
|
### Bug 4: Home and Navbar Brand Not Respecting Language
|
||
|
|
|
||
|
|
**Problem**: Clicking the "Home" link or navbar brand from `/en/cv/` would always redirect to `/` (German home) instead of `/en/` (English home), breaking language persistence.
|
||
|
|
|
||
|
|
**Root Cause**: Both links used `{{ .Site.BaseURL }}` which always points to the absolute root URL without language awareness.
|
||
|
|
|
||
|
|
**Solution**: Changed both to use Hugo's `relLangURL` function:
|
||
|
|
```html
|
||
|
|
<!-- Before -->
|
||
|
|
<a href="{{ .Site.BaseURL }}">Home</a>
|
||
|
|
|
||
|
|
<!-- After -->
|
||
|
|
<a href="{{ "/" | relLangURL }}">Home</a>
|
||
|
|
```
|
||
|
|
|
||
|
|
This makes the links language-aware, so:
|
||
|
|
- On German pages: `{{ "/" | relLangURL }}` → `/de/`
|
||
|
|
- On English pages: `{{ "/" | relLangURL }}` → `/en/`
|
||
|
|
|
||
|
|
**Lesson**: Never use `{{ .Site.BaseURL }}` for internal navigation in multilingual sites. Always use `relLangURL` or `absLangURL` to maintain language context.
|
||
|
|
|
||
|
|
### Bug 5: Menu URLs Not Language-Aware
|
||
|
|
|
||
|
|
**Problem**: The German "Lebenslauf" menu link pointed to `/cv/` which resulted in a 404 error because with `defaultContentLanguageInSubdir = true`, German pages are at `/de/cv/`, not `/cv/`.
|
||
|
|
|
||
|
|
**Root Cause**: Menu URLs in `hugo.toml` were hardcoded:
|
||
|
|
```toml
|
||
|
|
[[languages.de.menu.main]]
|
||
|
|
url = '/cv/' # Wrong - page doesn't exist here
|
||
|
|
```
|
||
|
|
|
||
|
|
**Solution**: Updated menu URLs to include the language prefix:
|
||
|
|
```toml
|
||
|
|
[[languages.de.menu.main]]
|
||
|
|
name = 'Lebenslauf'
|
||
|
|
url = '/de/cv/' # Correct
|
||
|
|
weight = 10
|
||
|
|
|
||
|
|
[[languages.en.menu.main]]
|
||
|
|
name = 'Curriculum Vitae'
|
||
|
|
url = '/en/cv/' # Correct
|
||
|
|
weight = 10
|
||
|
|
```
|
||
|
|
|
||
|
|
**Lesson**: When `defaultContentLanguageInSubdir = true`, even the default language requires the language prefix in all URLs. Don't assume the default language is accessible at the root paths.
|
||
|
|
|
||
|
|
## Best Practices Derived from Bugs
|
||
|
|
|
||
|
|
1. **Content Organization**: Use `filename.{lang}.md` pattern instead of `{lang}/filename.md` subdirectories to avoid template context issues.
|
||
|
|
|
||
|
|
2. **Translation Linking**: Always add `translationKey` to frontmatter for explicit translation relationships.
|
||
|
|
|
||
|
|
3. **URL Generation**: Use Hugo's language-aware functions:
|
||
|
|
- `relLangURL` for relative URLs
|
||
|
|
- `absLangURL` for absolute URLs
|
||
|
|
- `.RelPermalink` for page permalinks
|
||
|
|
- Never use `{{ .Site.BaseURL }}` for internal links
|
||
|
|
|
||
|
|
4. **Menu Configuration**: When `defaultContentLanguageInSubdir = true`, all menu URLs must include the language prefix, even for the default language.
|
||
|
|
|
||
|
|
5. **Testing**: Always test all pages in all languages, not just home pages. CV/subpages may render with different template contexts than home pages.
|