Multi-Language Support
A locale-aware string resolution system enabling Refrakt sites to render UI text, labels, accessibility strings, and structural headings in any language.
A locale-aware string resolution system enabling Refrakt sites to render UI text, labels, accessibility strings, and structural headings in any language.
All user-visible text generated by the transform pipeline, layout system, and client-side behaviors is currently hardcoded in English. Authors writing content in other languages see a mix of their content language and English UI chrome. This spec defines how to make that text localizable without breaking existing sites.
This spec covers the framework-generated text — labels, navigation chrome, accessibility strings, and structural headings injected by the identity transform, layouts, computed navigation, and behaviors. It does not cover content authoring language (that's the author's domain) or CLI/developer tooling strings (lower priority, English-only is acceptable).
An audit of the codebase identified ~120 distinct English strings across seven zones:
The label field on StructureEntry emits visible <span data-meta-label> elements. These appear across all config files:
packages/runes/src/config.ts): "Travelers:", "Duration:"runes/learning/): "Est. time:", "Difficulty:", "Prep:", "Cook:", "Serves:"runes/docs/): "Since:", "Deprecated:", "Source"runes/storytelling/): "Role:", "Status:", "Type:", "Scale:", "Category:", "Alignment:", "Size:", "Structure:"runes/places/): "Date:", "Location:", "Register"runes/plan/): "ID:", "Status:", "Priority:", "Complexity:", "Assignee:", "Milestone:", "Created:", "Modified:", "Severity:", "Target:", "Supersedes:", "Date:", "Name:"runes/marketing/): "Recommended", "Supported", "Not supported", "Not applicable"Strings created programmatically via makeTag() in postTransform hooks:
config.ts): "Total", "Per person", "Per day"runes/plan/src/pipeline.ts): "Relationships", "Progress", "criteria", KIND_LABELS ("Blocked by", "Blocks", "Related"), TYPE_LABELS ("work items", "bugs", etc.)packages/transform/src/layouts.ts — menu/search/navigation elements:
"Open menu", "Close menu", "Search", "Toggle navigation", "Navigation menu", "Page navigation", "Plan navigation""Search", "Menu", "Plan"packages/transform/src/computed.ts — injected navigation text:
"On this page" (ToC heading)"Previous" / "Next" (prev/next navigation)"Version" (version switcher label)Client-side JavaScript in packages/behaviors/src/:
"Copy code", "Copied""Previous", "Next", "Image lightbox", "Close lightbox", "Previous image", "Next image", "View image {n}""Preview", "View source", "Auto", "Light", "Dark", "System preference", "Light mode", "Dark mode", "Markdoc", "Rune", "HTML""Continue", "Start over""Search documentation...", "No results found.", "Search is not available.", "to navigate", "to select", "Esc""Filter rows...", "Prev", "Next""Submitting...", "Form submitted successfully.", "Something went wrong. Please try again.", "Select an option""Comparison slider", "Comparison toggle", "Panel {n}""Play", "Pause", "Seek""Sandbox""More info"Rune attribute values that double as visible display text:
note, warning, caution, check): Capitalized via capitalize transform and displayed as the hint title. An author writing {% hint type="warning" %} sees "Warning" in any locale."Before", "After""Details""Embedded content""Thin" through "Black"), pangram sample text, section titles ("Spacing", "Radius", "Shadows")"Constructor", "Properties", "Methods", "Static Properties", "Static Methods", "Accessors", "Index Signatures", "Class Methods")The planned knownSections feature (WORK-024, blocked on SPEC-003) declares expected section names with English aliases. This is both a localization concern and a CSS stability concern: buildSections() in runes/plan/src/util.ts derives data-name slugs from heading text, so non-English headings produce different slugs, breaking CSS selectors. knownSections would provide canonical slug keys independent of source language.
A locale section on ThemeConfig:
interface ThemeConfig { // ... existing fields ... /** Locale identifier (BCP 47). Defaults to 'en'. */ locale?: string; /** Translation strings keyed by dotted path. * Keys follow the pattern: {scope}.{identifier} * Scope is 'core', 'layout', 'behavior', or a package name. * Missing keys fall back to the English default baked into the config. */ strings?: Record<string, string>; }
Example for a German site:
{ locale: 'de', strings: { // Zone 1 — structure labels 'core.budget.duration': 'Dauer:', 'core.budget.total': 'Gesamt', 'core.budget.perDay': 'Pro Tag', // Zone 3 — layout chrome 'layout.openMenu': 'Menü öffnen', 'layout.closeMenu': 'Menü schließen', 'layout.search': 'Suche', 'layout.menu': 'Menü', 'layout.navigationMenu': 'Navigationsmenü', // Zone 4 — computed transforms 'core.toc.title': 'Auf dieser Seite', 'core.prevNext.previous': 'Zurück', 'core.prevNext.next': 'Weiter', 'core.versionSwitcher.label': 'Version', // Zone 5 — behaviors 'behavior.copy.copy': 'Code kopieren', 'behavior.copy.copied': 'Kopiert', 'behavior.gallery.previous': 'Vorheriges', 'behavior.gallery.next': 'Nächstes', 'behavior.search.placeholder': 'Dokumentation durchsuchen...', 'behavior.search.noResults': 'Keine Ergebnisse gefunden.', // Zone 6 — enum display values 'core.hint.note': 'Hinweis', 'core.hint.warning': 'Warnung', 'core.hint.caution': 'Achtung', 'core.hint.check': 'Erledigt', 'core.diff.before': 'Vorher', 'core.diff.after': 'Nachher', // Community package translations 'learning.howto.estimatedTime': 'Geschätzte Zeit:', 'learning.recipe.prep': 'Vorbereitung:', 'learning.recipe.cook': 'Kochen:', 'learning.recipe.serves': 'Portionen:', }, }
A resolveString(config, key, fallback) utility available to the engine, computed transforms, and layout builders:
function resolveString(config: ThemeConfig, key: string, fallback: string): string { return config.strings?.[key] ?? fallback; }
Zone 1 — Structure labels: The engine's buildStructureElement() already has access to the full config. When emitting a label, it resolves:
// Before (current) const labelText = entry.label; // After const labelText = resolveString(config, entry.labelKey ?? '', entry.label ?? '');
The labelKey is derived automatically from the rune config context: {packageScope}.{block}.{ref} — e.g., core.budget.duration for the Budget rune's duration label. Config authors don't need to set labelKey manually; the engine derives it from the structure path.
Zone 2 — postTransform text: postTransform hooks receive the config via a new config field on the context object, enabling resolveString() calls in programmatic code.
Zone 3 — Layout chrome: Layout config builders receive the theme config and use resolveString() for all text and aria-labels.
Zone 4 — Computed transforms: buildToc(), buildPrevNext(), buildVersionSwitcher() already receive config-derived data; they use resolveString() with keys like core.toc.title.
Zone 6 — Enum display values: When the capitalize transform is applied to a metaText value, the engine first checks for a translation key {scope}.{block}.{value} (e.g., core.hint.warning). If found, the translation replaces both the capitalize transform and the raw value.
Behaviors run in the browser with no access to server-side config. Two mechanisms deliver translations:
data-i18n-* attributes: The identity transform emits data-i18n-{key}={translated-value} on rune root elements for any behavior strings that have translations. Behaviors read these attributes instead of using hardcoded defaults.
<meta name="rf-locale"> tag: ThemeShell emits a <meta name="rf-locale" content="de"> tag and a <script type="application/json" id="rf-strings"> block containing behavior-scoped translations. The behavior init code reads this once and makes it available to all behaviors.
// In each behavior: const label = el.dataset.i18nCopy ?? getGlobalString('behavior.copy.copy') ?? 'Copy code';
Fallback chain: element attribute → global strings block → hardcoded English default. This means behaviors work identically in SSR-only mode (no JS) and in hydrated mode.
Community packages can ship translation bundles:
// runes/learning/src/index.ts export const learningPackage: RunePackage = { name: '@refrakt-md/learning', // ... existing fields ... translations: { de: { 'learning.howto.estimatedTime': 'Geschätzte Zeit:', 'learning.howto.difficulty': 'Schwierigkeit:', 'learning.recipe.prep': 'Vorbereitung:', 'learning.recipe.cook': 'Kochen:', 'learning.recipe.serves': 'Portionen:', }, fr: { 'learning.howto.estimatedTime': 'Temps estimé :', 'learning.howto.difficulty': 'Difficulté :', 'learning.recipe.prep': 'Préparation :', 'learning.recipe.cook': 'Cuisson :', 'learning.recipe.serves': 'Portions :', }, }, };
mergePackages() merges translation bundles. When the theme config specifies locale: 'de', the pipeline selects the de bundle from each loaded package and merges into config.strings, with theme-level overrides taking precedence.
When knownSections ships (WORK-024), it should integrate with the locale system:
knownSections: { 'Acceptance Criteria': { aliases: ['Criteria', 'AC', 'Done When'], // The canonical key for slug generation, independent of source language canonicalSlug: 'acceptance-criteria', // Per-locale heading aliases resolved from config.strings or inline i18nAliases: { de: ['Akzeptanzkriterien', 'Kriterien'], fr: ['Critères d\'acceptation', 'Critères'], }, model: { /* section-specific content model */ }, }, }
The canonicalSlug ensures stable data-name attributes regardless of the source language. Alias matching checks the base aliases, then the locale-specific aliases for the configured locale. This makes knownSections the key enabler for content portability — the same CSS works whether the author writes ## Acceptance Criteria or ## Akzeptanzkriterien.
The locale field enables locale-aware formatting:
"5h 30m". With locale support, it consults Intl.DurationFormat (or a polyfill) to produce "5 Std. 30 Min." in German.Intl.NumberFormat(config.locale) for locale-appropriate thousands separators and decimal marks.BUDGET_CURRENCY_SYMBOLS; Intl.NumberFormat with style: 'currency' would replace the manual symbol lookup.lang Attributepackages/html/src/page-shell.ts currently defaults to lang="en". With locale config, this becomes:
const lang = config.locale ?? 'en';
| Priority | Zone | Effort | Impact |
|---|---|---|---|
| P0 | ThemeConfig.locale + strings + resolveString() | Small | Foundation for everything else |
| P1 | Zone 1 (structure labels) | Medium | Highest visibility — affects all runes with metadata |
| P1 | Zone 4 (computed transforms) | Small | 4 strings, highly visible on every page |
| P1 | Zone 3 (layout chrome) | Small | ~12 strings, visible site-wide |
| P2 | Zone 5 (behaviors) | Medium | ~40 strings, requires client-side delivery mechanism |
| P2 | Zone 6 (enum display values) | Small | Hint titles, diff headers — common runes |
| P2 | Zone 2 (postTransform text) | Small | Budget and plan pipeline — fewer sites affected |
| P3 | Zone 7 (knownSections i18n) | Blocked | Depends on knownSections framework shipping first |
| P3 | Number/duration formatting | Small | Intl APIs handle most of the work |
refrakt inspect, refrakt plan, etc.packages/ai/ prompts are English-only and used for content generation, not end-user display.Key derivation for structure labels: Should keys be auto-derived from the config path ({package}.{block}.{ref}) or explicitly declared on each StructureEntry? Auto-derivation is less boilerplate but harder to discover; explicit keys are self-documenting but verbose.
Plural forms: Some strings need plural awareness (e.g., "3/10 criteria", "Per person"). Should we integrate Intl.PluralRules or keep it simple with template strings?
Translation file format: Should translations live in the RunePackage TypeScript export (as shown above), in separate JSON files per locale, or in a standard format like ICU MessageFormat?
Behavior string delivery: The <script type="application/json"> approach is simple but adds payload to every page. An alternative is a single /rf-strings.json endpoint that behaviors fetch once. Which is preferable?
Fallback chain depth: If a community package doesn't ship a translation for the configured locale, should it fall back to the package's default language, or to the theme-level strings, or directly to the hardcoded English?