Localization
Multi-language content, configuration, templates, and customization.
Localization
Chloroplast provides first-class localization so you can build multilingual sites without duplicating structure or forcing an English-first workflow. This page consolidates how to configure, author, render, and customize localized content.
1. Configuration
Add localization keys in SiteConfig.yml
:
defaultLocale
: The primary (authoring) language. Defaults toen
when omitted.supportedLocales
: List of all locales you want to serve. Must include the default. If omitted, Chloroplast treats the default as the only supported locale.
You can add/remove locales incrementally; untranslated pages gracefully fall back to the default language.
File Naming & Organization
You can localize content by either:
- Filename suffix:
guide.md
,guide.es.md
,guide.fr.md
- Sub‑directories per locale:
guide.md
,es/guide.md
,fr/guide.md
Mixing styles works, but choose one for consistency when possible.
URL Rules
- Default locale: no locale prefix (
/cli/
). - Non‑default locale:
/{locale}/...
(e.g.,/es/cli/
). - Base path (GitHub Pages etc.) is applied after locale logic, so
/Chloroplast/es/cli/
is valid when a base path is configured.
2. Rendering Model
Every page is represented by a ContentNode
that includes:
Locale
– the page's locale.Translations[]
– sibling translated nodes.IsMachineTranslated
– set via front matter (see below).IsFallback
– true when Chloroplast synthesized a locale version because no authored translation exists.
During build, Chloroplast groups translations and ensures each locale gets a resolvable URL. If a requested translation does not exist, the framework serves the default version while preserving locale context so templates can show a warning.
3. Front Matter Flags
Mark machine translations so users understand quality expectations:
---
title: Mi Página
machineTranslated: true
---
Valid key variations (all equivalent): machineTranslated
, machine_translated
, MachineTranslated
.
4. Template Helpers (Available in Razor Templates)
Common helpers exposed by the template base:
@CurrentLocale
– current page locale.@IsMachineTranslated
– whether front matter flagged it.@HasTranslation("es")
– true if a translation exists.@Href("/path")
– base path aware URL.@LocaleHref("/path", "es")
– locale + base path aware URL.@GetLocalizedPageUrl("es")
– URL for the current page in another locale (falls back gracefully).@GetCountryFlag("es")
– emoji flag (overridable method).@GetLocaleDisplayName("es")
– display name (overridable method).
Use these to build pickers, cross‑links, breadcrumbs, or notices without manual path logic.
5. Translation Warning Partial
Add a translation/machine‑translation notice automatically by including the partial:
@await PartialAsync("TranslationWarning")
Behavior:
- Shows a missing translation warning when a user is viewing a fallback (
IsFallback
) for their locale. - Shows a machine translation notice when
IsMachineTranslated
is set. - Localized messaging: provide
TranslationWarning.{locale}.cshtml
for each supported language (an English + Spanish implementation is already included in the starter templates).
Customizing the Warning
Copy the existing partial and modify the text/markup. If you need richer logic, replicate the minimal conditions used internally (has translation?
, IsMachineTranslated
) and style to your needs.
Manual Snippets
If you prefer not to use the partials, you can add explicit notices directly in a template:
@* Missing translation (fallback) *@
@if (IsFallback && CurrentLocale != SiteConfig.DefaultLocale)
{
<div class="translation-warning" role="alert">
<strong>⚠️ Translation Notice:</strong>
This content is not available in @GetLocaleDisplayName(CurrentLocale).
You are viewing the @GetLocaleDisplayName(SiteConfig.DefaultLocale) version.
<a href="@GetLocalizedPageUrl(SiteConfig.DefaultLocale)" class="translation-warning-link">
View in @GetLocaleDisplayName(SiteConfig.DefaultLocale)
</a>
</div>
}
@* Machine translation notice *@
@if (IsMachineTranslated)
{
<div class="machine-translation-warning" role="alert">
<strong>🤖 Machine Translation:</strong>
This content was automatically translated and may contain errors.
</div>
}
Suggested minimal styling (merge / adapt to your main stylesheet):
.translation-warning, .machine-translation-warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 12px;
margin: 16px 0;
color: #856404;
font-size: 14px;
}
.translation-warning-link { color: #856404; text-decoration: underline; font-weight: 500; }
.translation-warning-link:hover { color: #533f03; }
6. Locale Picker
Implement a user‑selectable language menu via a Razor partial (see the reference component documented separately). Key concepts:
- Iterate
SiteConfig.SupportedLocales
. - Mark the active locale (e.g.,
aria-current="page"
). - Use
GetLocalizedPageUrl(locale)
to link each option. - Use
GetCountryFlag(locale)
+GetLocaleDisplayName(locale)
for concise, accessible labels.
Include the picker where appropriate:
@await PartialAsync("LocalePicker")
Keyboard + ARIA patterns are shown in the component reference (LocalePickerComponent.md
).
Reference Implementation
Below is an inline reference you can copy into a partial named LocalePicker.cshtml
(the separate component doc has been inlined here for convenience):
@{
var supportedLocales = SiteConfig.SupportedLocales;
var currentLocale = Model?.Node?.Locale ?? SiteConfig.DefaultLocale;
}
@if (supportedLocales.Length > 1)
{
<div class="locale-picker">
<button type="button" class="locale-picker-toggle" aria-expanded="false" aria-haspopup="true">
@GetCountryFlag(currentLocale) @GetLocaleDisplayName(currentLocale)
<span class="locale-picker-arrow">▼</span>
</button>
<ul class="locale-picker-menu" role="menu" aria-hidden="true">
@foreach (var locale in supportedLocales)
{
var isActive = locale == currentLocale;
var localizedUrl = GetLocalizedPageUrl(locale);
<li role="none">
<a href="@localizedUrl" role="menuitem" class="locale-picker-item @(isActive ? "active" : "")" @(isActive ? "aria-current=\"page\"" : "")>
@GetCountryFlag(locale) @GetLocaleDisplayName(locale)
</a>
</li>
}
</ul>
</div>
}
Include the partial where desired:
@await PartialAsync("LocalePicker")
CSS (Add to your main stylesheet)
.locale-picker { position: relative; display: inline-block; }
.locale-picker-toggle { background:#f8f9fa; border:1px solid #dee2e6; border-radius:4px; padding:8px 12px; cursor:pointer; display:flex; align-items:center; gap:8px; font-size:14px; color:#495057; }
.locale-picker-toggle:hover { background:#e9ecef; border-color:#adb5bd; }
.locale-picker-arrow { font-size:10px; transition: transform .2s ease; }
.locale-picker[aria-expanded="true"] .locale-picker-arrow { transform: rotate(180deg); }
.locale-picker-menu { position:absolute; top:100%; right:0; background:#fff; border:1px solid #dee2e6; border-radius:4px; box-shadow:0 2px 8px rgba(0,0,0,.1); list-style:none; margin:4px 0 0; padding:4px 0; min-width:150px; z-index:1000; display:none; }
.locale-picker[aria-expanded="true"] .locale-picker-menu { display:block; }
.locale-picker-item { display:block; padding:8px 12px; color:#495057; text-decoration:none; font-size:14px; white-space:nowrap; }
.locale-picker-item:hover { background:#f8f9fa; color:#212529; }
.locale-picker-item.active { background:#007bff; color:#fff; }
.locale-picker-item.active:hover { background:#0056b3; }
JavaScript (defer or inline near the end of body
)
document.addEventListener('DOMContentLoaded', () => {
const picker = document.querySelector('.locale-picker');
if (!picker) return;
const toggle = picker.querySelector('.locale-picker-toggle');
const menu = picker.querySelector('.locale-picker-menu');
const open = () => { toggle.setAttribute('aria-expanded','true'); menu.setAttribute('aria-hidden','false'); picker.setAttribute('aria-expanded','true'); };
const close = () => { toggle.setAttribute('aria-expanded','false'); menu.setAttribute('aria-hidden','true'); picker.setAttribute('aria-expanded','false'); };
toggle.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); (toggle.getAttribute('aria-expanded')==='true') ? close() : open(); });
document.addEventListener('click', e => { if (!picker.contains(e.target)) close(); });
document.addEventListener('keydown', e => { if (e.key==='Escape') { close(); toggle.focus(); } });
menu.addEventListener('keydown', e => {
const items = menu.querySelectorAll('.locale-picker-item');
const list = Array.from(items);
const idx = list.indexOf(document.activeElement);
if (e.key==='ArrowDown') { e.preventDefault(); list[(idx+1)%list.length].focus(); }
else if (e.key==='ArrowUp') { e.preventDefault(); list[(idx-1+list.length)%list.length].focus(); }
else if (e.key===' ' || e.key==='Enter') { if (document.activeElement.classList.contains('locale-picker-item')) { e.preventDefault(); document.activeElement.click(); } }
});
});
Accessibility notes:
- Uses ARIA menu roles for screen reader clarity.
- Keyboard navigation (ArrowUp/Down, Enter/Space, Escape) supported.
aria-current="page"
marks the active locale.
7. Customizing Flags & Names
Default mappings intentionally cover only a minimal set (currently English and Spanish) and fall back to a generic globe emoji and uppercase locale code. To customize:
- Create a derived template base OR override methods directly in a specific Razor template via an inline
@functions
/ code block. - Override
GetCountryFlag(locale)
and/orGetLocaleDisplayName(locale)
with your own switch / lookup table (ISO 639/3166, database, etc.).
Tip: Keep mapping logic centralized so all partials (picker, warnings, navigation) stay consistent.
Using locale-config.yml
For larger sets of locales (or to avoid recompiling templates) you can define a data-driven mapping in the template folder via locale-config.yml
(see the starter file under templates/
). It provides a locales:
map whose keys are locale codes and whose values specify:
flag
– the emoji or short marker to show.displayName
– the native-language name you want rendered.
There is also a default:
section used when a locale code is not explicitly listed (falls back to a globe + generic label by default). This file lets you:
- Add many locales without changing C# or Razor code.
- Standardize naming (e.g., choose between “Português (BR)” vs “Português”).
- Remove or replace flags with text-only markers if emojis are undesired.
How It’s Consumed
Templates may read and deserialize this YAML (for example in a custom base template) and then surface the values when implementing GetCountryFlag()
/ GetLocaleDisplayName()
. If you provide both overridden methods AND a locale-config.yml
, prefer the file’s values inside those overrides so authors can update display names without code changes.
Custom Strategy
- Keep
locale-config.yml
alongside other templates so it ships with your theme. - In your template base override, lazy-load and cache the parsed YAML into a dictionary keyed by locale.
- Return the config value or fall back to the built-ins and finally to the
default:
entry. - Optionally expose a helper (e.g.,
@GetAllConfiguredLocales()
) that enumerates only those you actually want in the picker (could differ fromsupportedLocales
if you stage future locales).
You still must list every actively served locale in
supportedLocales
insideSiteConfig.yml
for routing and build output. Thelocale-config.yml
file is only for presentation layer data (flags, display names) and doesn’t control which locales get generated.
8. Fallback Behavior & UX
When a locale page is requested but no authored translation exists:
- Chloroplast serves the default language.
IsFallback
is true allowing a notice.- Picker still shows other locales; the current locale remains the requested one so the user understands context.
- SEO: canonical and
hreflang
entries (via sitemap) reflect available translations.
9. Sitemaps & SEO
Generated sitemaps include hreflang
alternates for each set of translations so search engines properly index language variants. Ensure baseUrl
is configured for sitemap generation.
Example excerpt:
<url>
<loc>https://example.com/docs/guide</loc>
<xhtml:link rel="alternate" hreflang="es" href="https://example.com/es/docs/guide" />
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/docs/guide" />
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/docs/guide" />
</url>
10. Authoring Workflow Tips & Best Practices
- Start with your primary language: set
defaultLocale
to the language you author in. - Translate incrementally; untranslated pages work immediately via fallback.
- Prefer consistent slug names across locales to maximize automatic grouping.
- Choose one organization style (filename suffix OR sub-directory) for clarity.
- Include the locale picker early for user discoverability.
- Test with a configured
basePath
(e.g., GitHub Pages) to ensure prefixed locale URLs resolve. - Add machine translation flags early so you remember to revisit content later.
- Keep custom locale assets (flags, names) centralized (helpers or
locale-config.yml
). - Document a review process for machine-translated pages to graduate them to human-reviewed.
- Periodically audit sitemap output to verify
hreflang
integrity after adding locales.
11. Quick Checklist
- [ ] Add
defaultLocale
+supportedLocales
toSiteConfig.yml
- [ ] Add / update localized markdown files (
file.es.md
ores/file.md
) - [ ] Include
@await PartialAsync("TranslationWarning")
and locale picker - [ ] Provide
TranslationWarning.{locale}.cshtml
partials for each language - [ ] Override flag/display helpers if you need custom names or icons
- [ ] Run build and validate localized URLs & sitemap
Need the implementation reference? See the existing component and guide files in the repo (LocalizationGuide.md
, LocalePickerComponent.md
).
Return to Home.
12. Migration from Existing Non-localized Sites
You can add localization without breaking existing deployments:
- Add
defaultLocale
andsupportedLocales
toSiteConfig.yml
(include the default in the list). - Your existing content is automatically treated as the default locale.
- Add translated files using your chosen naming strategy (
file.es.md
ores/file.md
). - Introduce the locale picker & translation warning partials when ready (site builds fine without them).
- Add machine translation flags where applicable so you can queue human review later.
- Rebuild and confirm new localized URLs plus sitemap
hreflang
entries.
No reorganization of existing default-language content is required; localization is additive and opt-in.