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.
Motivation
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.
Scope
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).
Inventory of Localizable Text
An audit of the codebase identified ~120 distinct English strings across seven zones:
Zone 1 — Structure Entry Labels (~60 strings)
The label field on StructureEntry emits visible <span data-meta-label> elements. These appear across all config files:
- Core (
packages/runes/src/config.ts):"Travelers:","Duration:" - Learning (
runes/learning/):"Est. time:","Difficulty:","Prep:","Cook:","Serves:" - Docs (
runes/docs/):"Since:","Deprecated:","Source" - Storytelling (
runes/storytelling/):"Role:","Status:","Type:","Scale:","Category:","Alignment:","Size:","Structure:" - Places (
runes/places/):"Date:","Location:","Register" - Plan (
runes/plan/):"ID:","Status:","Priority:","Complexity:","Assignee:","Milestone:","Created:","Modified:","Severity:","Target:","Supersedes:","Date:","Name:" - Marketing (
runes/marketing/):"Recommended","Supported","Not supported","Not applicable"
Zone 2 — postTransform Text (~10 strings)
Strings created programmatically via makeTag() in postTransform hooks:
- Budget (
config.ts):"Total","Per person","Per day" - Plan pipeline (
runes/plan/src/pipeline.ts):"Relationships","Progress","criteria", KIND_LABELS ("Blocked by","Blocks","Related"), TYPE_LABELS ("work items","bugs", etc.)
Zone 3 — Layout Chrome (~12 strings)
packages/transform/src/layouts.ts — menu/search/navigation elements:
- aria-labels:
"Open menu","Close menu","Search","Toggle navigation","Navigation menu","Page navigation","Plan navigation" - Visible text:
"Search","Menu","Plan"
Zone 4 — Computed Transforms (~4 strings)
packages/transform/src/computed.ts — injected navigation text:
"On this page"(ToC heading)"Previous"/"Next"(prev/next navigation)"Version"(version switcher label)
Zone 5 — Behavior Strings (~40 strings)
Client-side JavaScript in packages/behaviors/src/:
- copy.ts:
"Copy code","Copied" - gallery.ts:
"Previous","Next","Image lightbox","Close lightbox","Previous image","Next image","View image {n}" - preview.ts:
"Preview","View source","Auto","Light","Dark","System preference","Light mode","Dark mode","Markdoc","Rune","HTML" - reveal.ts:
"Continue","Start over" - search.ts:
"Search documentation...","No results found.","Search is not available.","to navigate","to select","Esc" - datatable.ts:
"Filter rows...","Prev","Next" - form.ts:
"Submitting...","Form submitted successfully.","Something went wrong. Please try again.","Select an option" - juxtapose.ts:
"Comparison slider","Comparison toggle","Panel {n}" - audio.ts:
"Play","Pause","Seek" - sandbox.ts:
"Sandbox" - map.ts:
"More info"
Zone 6 — Schema Defaults and Enum-as-Text (~15 strings)
Rune attribute values that double as visible display text:
- Hint type (
note,warning,caution,check): Capitalized viacapitalizetransform and displayed as the hint title. An author writing{% hint type="warning" %}sees "Warning" in any locale. - Diff headers:
"Before","After" - Details fallback:
"Details" - Embed fallback:
"Embedded content" - Design typography: Weight names (
"Thin"through"Black"), pangram sample text, section titles ("Spacing","Radius","Shadows") - Docs extract: Symbol group labels (
"Constructor","Properties","Methods","Static Properties","Static Methods","Accessors","Index Signatures","Class Methods")
Zone 7 — knownSections (Unbuilt)
The planned knownSections feature (Add knownSections to Plan Rune Content Models, blocked on Declarative Content Model — Specification) 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.
Design
Principles
- Zero-config English: Existing sites work unchanged. English is the fallback when no locale is configured.
- Additive localization: Adding a language means providing a strings dictionary — no structural changes to rune configs.
- Package-scoped translations: Community packages ship their own translations alongside their rune configs.
- Single resolution path: All localizable text resolves through one mechanism, whether it originates from structure labels, computed transforms, or behaviors.
- Type-safe keys: Translation keys are derived from existing config, not invented separately. Missing translations fall back to the English literal.
Locale Configuration
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.travelers': 'Reisende:',
'core.budget.duration': 'Dauer:',
'core.budget.total': 'Gesamt',
'core.budget.perPerson': 'Pro Person',
'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:',
},
}
Resolution Mechanism
Server-side (Zones 1–4, 6)
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.travelers for the Budget rune's travelers 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.
Client-side (Zone 5 — Behaviors)
Behaviors run in the browser with no access to server-side config. Two mechanisms deliver translations:
data-i18n-*attributes: The identity transform emitsdata-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:ThemeShellemits 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.
Package Translation Bundles
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.
Interaction with knownSections
When knownSections ships (Add knownSections to Plan Rune Content Models), 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.
Number and Duration Formatting
The locale field enables locale-aware formatting:
- Duration transform: Currently outputs
"5h 30m". With locale support, it consultsIntl.DurationFormat(or a polyfill) to produce"5 Std. 30 Min."in German. - Number formatting: Budget amounts use
Intl.NumberFormat(config.locale)for locale-appropriate thousands separators and decimal marks. - Currency: Already partially handled by
BUDGET_CURRENCY_SYMBOLS;Intl.NumberFormatwithstyle: 'currency'would replace the manual symbol lookup.
HTML lang Attribute
packages/html/src/page-shell.ts currently defaults to lang="en". With locale config, this becomes:
const lang = config.locale ?? 'en';
Implementation Zones and Priorities
| 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 |
Non-Goals
- Content translation / multi-language sites: This spec does not address serving the same site in multiple languages (route-based locale switching, parallel content trees). That is a larger feature that could build on this foundation.
- RTL layout support: Right-to-left text direction is a CSS/layout concern orthogonal to string translation. Worth a separate spec.
- CLI / developer tooling i18n: English-only is acceptable for
refrakt inspect,refrakt plan, etc. - AI prompt translation: The
packages/ai/prompts are English-only and used for content generation, not end-user display.
Open Questions
Key derivation for structure labels: Should keys be auto-derived from the config path (
{package}.{block}.{ref}) or explicitly declared on eachStructureEntry? 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 integrateIntl.PluralRulesor keep it simple with template strings?Translation file format: Should translations live in the
RunePackageTypeScript 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.jsonendpoint 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?