v0.29.0
Released July 23, 2026 · All releases
Multi-language support (i18n foundation). Makes Refrakt's framework-generated text — labels, navigation chrome, accessibility strings, structural headings, and client-side behavior strings — localizable, so a site built for a non-English locale no longer shows English UI chrome around translated content. This milestone delivers the string-resolution foundation of Multi-Language Support. Serving the same site in multiple languages (route trees, switcher, auto-detect) remains a deliberate Non-Goal that this foundation is explicitly constrained to allow later without breaking changes.
Changes
- Multi-language support (i18n foundation) — SPEC-035
- Framework-generated text (labels, navigation chrome, accessibility strings, structural headings, and client-side behavior strings) is now localizable. Zero-config English is unchanged — adding a language means providing a strings dictionary, no structural changes.
- Locale config:
locale+stringsonThemeConfigandSiteConfig; a render-scopedLocaleContextwithresolveLocaleString/resolvePluralString(CLDR plurals viaIntl.PluralRules) and per-key first-match fallback with BCP-47 region stripping (de-AT→de). - Auto-derived keys: labels resolve through
{scope}.{block}.{ref}keys (optionali18nKeyoverride); covers structure/meta-field labels, layout chrome, computed navigation, programmatic text (data-i18n), and enum-as-text values. refrakt i18n extract(CLI +refrakt.i18n_extractMCP tool): emits the full key→English dictionary as JSON;--checkreports per-locale coverage and fails on drift.- Behavior strings: delivered inline (
<meta name="rf-locale">+<script id="rf-strings">), resolved element→block→English synchronously with no fetch and no flash-of-English. - Plugin translation bundles:
Plugin.translations, merged bymergePluginsunder site overrides (site wins). - knownSections:
canonicalSlug+i18nAliasesfor language-stable section slugs. - Cross-adapter
<html lang>+ locale-awareIntlduration/number/currency. - First-party bundles: in-package per-locale JSON (
i18n/<locale>.json) with a seeded German translation; partial bundles degrade per key to English.
Multi-Language Support
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.
- Version
- 2.2
- Released in
- v0.29.0
Revision Note
v2.0 (2026-07) — reconciled against the current codebase. The intent and design principles are unchanged, but several concrete references were stale and the string inventory had drifted:
RunePackage→Plugin: the package registration type was renamed.mergePackages()→mergePlugins(),loadRunePackage()→loadPlugin(). The proposedtranslationsbundle now hangs offPlugin/PluginThemeConfig.- Second label path: since SPEC-080 labels also emit through the
metaFields/blocksmodel (MetaField.label), not onlyStructureEntry.label. Both must be localized. buildStructureElement()has no config access today — a real, previously-unstated implementation cost.- Inventory refresh: diff headers moved to CSS (no longer a JS surface); core budget labels (
"Travelers","Duration","Per person") were removed; the plan pipelineKIND_LABELS/TYPE_LABELSconstants no longer exist; behaviors split intobehaviors/+elements/and gained new string-bearing files; thebusiness,media, anddesignplugins were never inventoried. - knownSections shipped (WORK-024, delivered via SPEC-037) — but without the
canonicalSlug/i18nAliaseslocalization shape this spec called for. Zone 7 is reframed accordingly. - Naming: the proposed
resolveString()helper is renamedresolveLocaleString()to avoid colliding with the unrelatedresolveString()data-pipeline variable interpolator inpackages/runes/src/data-pipeline.ts.
v2.1 (2026-07) — resolved all six open questions plus two scope decisions (first-party translations, packaging). See the Decisions section; the design below reflects the chosen answers (auto-derived keys + extract tooling, Intl.PluralRules, JSON-per-locale, inline behavior delivery, per-key fallback, LocaleContext threading).
v2.2 (2026-07) — added the Forward Compatibility — Multi-Locale Sites section. Multi-locale (route-based locale trees, switcher, auto-detect) remains a Non-Goal and demand-driven, but this design is constrained so it can be added later without breaking changes.
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 identifies ~120+ distinct English strings across seven zones. Directory note: the plugin tree moved from the spec's original runes/<name>/ convention to plugins/<name>/src/; the behaviors package split into packages/behaviors/src/behaviors/ (DOM behaviors) and packages/behaviors/src/elements/ (custom elements).
Zone 1 — Structure Entry & MetaField Labels (~68 strings)
Visible labels are emitted through two mechanisms:
StructureEntry.label(packages/transform/src/types.ts) — the legacy path, emits<span data-meta-label>.MetaField.label(packages/transform/src/types.ts, the SPEC-080metaFields/blocksmodel) — emits<span data-meta-label>inside a chip (chip-row) or as the<dt>of a definition list.
Both take a literal string and must resolve through the locale system. Current label strings by scope:
- Core (
packages/runes/src/config.ts/packages/runes/src/tags/budget.ts): budget"Total","Per day". (Note: the v1.0 references to core"Travelers:","Duration:","Per person"are removed from the codebase — do not re-inventory them.) - Learning (
plugins/learning/src/):"Est. time","Difficulty","Prep","Cook","Serves" - Docs (
plugins/docs/src/):"Since","Deprecated","Source","Version","Released in","PR","Tags" - Storytelling (
plugins/storytelling/src/):"Role","Status","Type","Scale","Category","Alignment","Size","Structure" - Places (
plugins/places/src/):"Date","Location","Register","Ends" - Plan (
plugins/plan/src/):"ID","Status","Priority","Complexity","Assignee","Milestone","Created","Modified","Severity","Target","Supersedes","Source", plusachievedLabelvalues ("Fixed","Accepted","Complete","Done") - Marketing (
plugins/marketing/src/tags/comparison.ts):"Recommended", plus"Supported"/"Not supported"/"Not applicable"aria-labels - Business (
plugins/business/src/): mostly author-supplied labels (emitAttributes: { label: '$label' }) — low fixed-string surface, but auditcast.ts/organization.tsfor fixed headings.
Trailing colons are now added by CSS, so label values themselves omit the :.
Zone 2 — postTransform / Programmatic Text (~6 strings)
Strings created programmatically (e.g. via makeTag()), significantly reduced since v1.0:
- Budget (
packages/runes/src/tags/budget.ts):"Total","Per day". - Plan pipeline: the v1.0
KIND_LABELS/TYPE_LABELSconstants no longer exist."Blocked by"/"Blocks"are now section-name constants (plugins/plan/src/scanner-core.ts) and section keys in the plan tag files (tags/work.ts,tags/bug.ts, with"Related"as an alias). The pipeline render surface moved to a section/render model (plugins/plan/src/render.ts,commands/render-pipeline.ts) and needs a fresh audit for any remaining literals ("Relationships","Progress","criteria").
Zone 3 — Layout Chrome (~14 strings)
packages/transform/src/layouts.ts — menu/search/navigation elements (intact):
- aria-labels:
"Open menu","Close menu","Search","Toggle navigation","Navigation menu","Page navigation","Plan navigation", plus newer"Toggle color theme","Jump to section" - Visible text:
"Search","Menu","Plan"
Zone 4 — Computed Transforms (~4 strings)
packages/transform/src/computed.ts — injected navigation text (intact):
buildToc()→"On this page"(ToC heading)buildPrevNext()→"Previous"/"Next"buildVersionSwitcher()→"Version"
Zone 5 — Behavior Strings (~45 strings)
Client-side JavaScript, now split across packages/behaviors/src/behaviors/ and packages/behaviors/src/elements/:
- behaviors/copy.ts:
"Copy code","Copied" - behaviors/gallery.ts:
"Image lightbox","Close lightbox","Previous image","Next image","View image {n}" - behaviors/preview.ts:
"Preview","View source","Auto","Light","Dark","System preference","Light mode","Dark mode","Markdoc","Rune","HTML" - behaviors/reveal.ts:
"Continue","Start over" - behaviors/search.ts:
"Search documentation...","No results found.","Search is not available.","to navigate","to select","Esc" - behaviors/datatable.ts:
"Filter rows...","Prev","Next" - behaviors/form.ts:
"Submitting...","Form submitted successfully.","Something went wrong. Please try again.","Select an option" - behaviors/juxtapose.ts:
"Comparison slider","Comparison toggle","Panel {n}" - elements/audio.ts:
"Play","Pause","Seek" - elements/sandbox.ts:
"Sandboxed user content" - elements/map.ts:
"More info"
New behavior files not in v1.0 (must be inventoried): behaviors/carousel.ts ("Previous", "Next"), behaviors/mobile-menu.ts ("Open menu", "Close menu"), behaviors/section-nav.ts / scrollspy.ts ("Page sections"). Additional element behaviors (elements/chart.ts, elements/diagram.ts, elements/nav.ts, elements/context.ts) should be scanned for axis/legend/loading strings.
Zone 6 — Schema Defaults and Enum-as-Text (~15 strings)
Rune attribute values that double as visible display text:
- Hint type (
packages/runes/src/tags/hint.ts:note,warning,caution,check): capitalized and displayed as the hint title.{% hint type="warning" %}renders "Warning" in any locale. - Details fallback (
packages/runes/src/tags/details.ts):"Details" - Embed fallback (
packages/runes/src/tags/embed.ts):"Embedded content" - Design typography (
plugins/design/src/tags/typography.ts): weight names ("Thin"…"Black"), pangram sample text, section titles. - Design palette (
plugins/design/src/tags/palette.ts, new since v1.0): contrast readout"W: … · B: …", accessibility badges"AAA"/"AA"(✓/✗). - Docs extract: symbol group labels (
"Constructor","Properties","Methods","Static Properties","Static Methods","Accessors","Index Signatures","Class Methods") — now duplicated acrossplugins/docs/src/extract/typescript.tsandplugins/docs/src/extract/python.ts; both must be localized.
(Removed since v1.0: diff "Before"/"After" are no longer JS string literals — packages/runes/src/tags/diff.ts emits lowercase data-name="before"/data-name="after" and the visible labels are CSS-generated. Diff localization is therefore a CSS/content: concern, out of scope for the string table.)
Zone 7 — knownSections (SHIPPED; slug-stability follow-up outstanding)
The knownSections feature is now implemented (WORK-024, done; delivered via SPEC-037, with SPEC-003 accepted). Today:
KnownSectionDefinition(packages/types/src/content-model.ts) declares canonical section names with analias?: string[]list and an optional per-sectionmodel.- The resolver (
packages/runes/src/lib/resolver.ts) matches heading text case-insensitively against the canonical name + aliases, attaching$canonicalNameto matched sections. buildSections()(plugins/plan/src/util.ts) setsdata-known-section={canonicalName}on the matched heading.
What was NOT built — and remains the localization concern this spec owns: there is no canonicalSlug and no i18nAliases. slugify() in plugins/plan/src/util.ts still derives the section wrapper's data-name from raw heading text, so a non-English heading (## Akzeptanzkriterien) still produces a different data-name slug than ## Acceptance Criteria, breaking CSS selectors keyed on it. The data-known-section attribute provides a stable hook on the heading, but not a stable slug on the <section> wrapper.
The remaining work is therefore a scoped follow-up, no longer blocked on an unbuilt framework:
- Derive the section wrapper's
data-namefrom the matched canonical key (a stablecanonicalSlug) instead of heading text, falling back toslugify($heading)for unrecognised sections. - Add per-locale heading aliases (
i18nAliasesor resolved fromconfig.strings) so authors can write headings in the site locale and still hit the canonical section.
Zone 8 — HTML lang Attribute (cross-adapter)
The document lang attribute must reflect the configured locale. This is no longer a single-file change: with the framework adapters (SPEC-030, SPEC-058), each adapter's page shell (html, astro, nuxt, next, eleventy, sveltekit) sets lang. All must read config.locale ?? 'en'.
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.
- Plugin-scoped translations: Plugins ship their own translations alongside their rune configs.
- Single resolution path: All localizable text resolves through one mechanism, whether it originates from structure labels, meta fields, 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.
Value Types
A translated value is either a plain string or — for the small set of count-bearing strings (Decision D2) — a plural category map:
/** CLDR plural categories, as returned by Intl.PluralRules. */
type PluralMap = Partial<Record<Intl.LDMLPluralRule, string>>; // 'zero'|'one'|'two'|'few'|'many'|'other'
/** A dictionary value: a literal string, or a per-category map for plurals. */
type LocalizedValue = string | PluralMap;
Locale Configuration
A locale section on ThemeConfig (packages/transform/src/types.ts — neither field exists today; both are net-new):
interface ThemeConfig {
// ... existing fields: prefix, tokenPrefix, icons, runes, tints?, backgrounds?, frames? ...
/** 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 plugin name.
* Missing keys fall back to the English default baked into the config. */
strings?: Record<string, LocalizedValue>;
}
Example for a German site:
{
locale: 'de',
strings: {
// Zone 1 — structure / meta-field labels
'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',
// A plural value (Decision D2)
'plan.progress.criteria': { one: '{n} Kriterium', other: '{n} Kriterien' },
// Plugin 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 resolveLocaleString(ctx, key, fallback) utility available to the engine, computed transforms, and layout builders, plus a plural-aware sibling. ctx is a narrow LocaleContext slice (Decision D6), not the full ThemeConfig:
interface LocaleContext {
locale: string; // BCP 47, defaults to 'en'
strings: Record<string, LocalizedValue>; // merged, locale-selected dictionary
}
function resolveLocaleString(ctx: LocaleContext, key: string, fallback: string): string {
const v = ctx.strings[key];
return typeof v === 'string' ? v : fallback;
}
function resolvePluralString(ctx: LocaleContext, key: string, count: number, fallback: string): string {
const v = ctx.strings[key];
if (v && typeof v === 'object') {
const cat = new Intl.PluralRules(ctx.locale).select(count);
return (v[cat] ?? v.other ?? fallback).replace('{n}', String(count));
}
return typeof v === 'string' ? v.replace('{n}', String(count)) : fallback;
}
Named
resolveLocaleString(notresolveString) to avoid colliding with the existing, unrelatedresolveString()inpackages/runes/src/data-pipeline.ts, which interpolates{{ }}template variables against data-pipeline sources.
Plurals and counts (Decision D2): Framework chrome prefers count-neutral phrasing ("Progress 3/10") so pluralization rarely arises. Where a natural phrasing genuinely inflects, the dictionary value is a PluralMap resolved via Intl.PluralRules for the active locale — English supplies { one, other }; Polish/Russian/Arabic supply the additional categories. No English n === 1 ternaries, and no ICU MessageFormat engine: the CLDR category model reached via Intl.PluralRules is identical to ICU's plural model without the parser or the client-bundle dependency. Revisit ICU only if framework chrome grows variable-laden sentences or a TMS workflow is adopted — and note the resolver dispatches on value shape, so a value could later become an ICU string behind the same key without a format-wide migration.
Zone 1 — Structure & MetaField labels: Both label paths must resolve through the locale table.
- Implementation cost:
buildStructureElement()(packages/transform/src/engine.ts) currently receives only(entry, name, modifierValues, icons)— it has no access to the config. TheMetaFieldrenderers likewise emitfield.labelliterally. Localization threads a narrowLocaleContext(Decision D6) intobuildStructureElement()and the MetaField block renderers — matching the engine's existing convention of passing explicit slices (icons,modifierValues) rather than the whole config. - The label key is auto-derived from the config context:
{pluginScope}.{block}.{ref}— e.g.,core.budget.total(Decision D1). Authors don't set keys manually; an optionali18nKeyoverride on a field pins a stable key across refactors. Discoverability is handled by therefrakt i18n extracttooling (below), not by verbose per-label keys.
Zone 2 — postTransform / programmatic text: postTransform hooks (and the plan render pipeline) receive the LocaleContext so they can call resolveLocaleString() in programmatic code.
Zone 3 — Layout chrome: Layout config builders (layouts.ts) receive the LocaleContext and use resolveLocaleString() for all text and aria-labels.
Zone 4 — Computed transforms: buildToc(), buildPrevNext(), buildVersionSwitcher() (computed.ts) already receive config-derived data; they use resolveLocaleString() 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) — Decision D4
Behaviors run in the browser with no access to server-side config. Translations are delivered inline (no fetched endpoint):
data-i18n-*attributes: The identity transform emitsdata-i18n-{key}={translated-value}on rune root elements for behavior strings that attach to an element the transform already renders. Behaviors read these instead of hardcoded defaults. Prefer this path wherever a string has an anchor element.<meta name="rf-locale">tag + inline JSON block:ThemeShell(and each adapter's page shell) emits<meta name="rf-locale" content="de">and a<script type="application/json" id="rf-strings">block. The block carries only the strings behaviors create at runtime with no anchor element (e.g."No results found."injected by search, the"Copied"state, dynamically-built gallery labels) — keeping it to ~15–20 strings, not all 45. Only the active locale is emitted.
// In each behavior:
const label = el.dataset.i18nCopy ?? getGlobalString('behavior.copy.copy') ?? 'Copy code';
Fallback chain: element attribute → inline global block → hardcoded English default. Because the block is inline, strings are available synchronously at behavior init — no flash-of-English, no interactivity delay, and pages stay self-contained (works in SSR-only / no-JS mode). A single fetched /rf-strings.json was rejected: the payload is a couple KB, the caching win is marginal, and a fetch forces either blocked interactivity or a visible English→translated swap on the most visible chrome — plus per-adapter routing, base-path, and cache-invalidation cost. Revisit only if the behavior-string set grows large or soft-navigation duplication becomes measurable.
Resolution Precedence and Fallback — Decision D5
For a given key, resolution is first-match-wins, per key (a scalar lookup, not a deep merge of dictionaries):
- Site override —
ThemeConfig.strings[key]. Always wins; lets a site owner fix a shipped translation, adjust tone, or match brand terminology. - Owning package's shipped bundle for the resolved locale — the plugin's
translationsfor plugin-scoped keys, core's bundle forcore.*/layout.*/behavior.*. Locale-tag fallback applies here:de-AT→de(progressive BCP-47 subtag stripping) before dropping through. - Hardcoded English literal — the
fallbackpassed at the call site. Always present, so it is the universal floor.
There is no per-plugin "default language": an untranslated key falls to English, never to some third language the site owner never asked for. Degradation is per key, not per language — a missing de key shows English inline while the rest of the page stays German, which is what makes shipping partial first-party bundles safe. The --check tooling below turns those per-key gaps into a measured coverage number.
Plugin Translation Bundles
Plugins ship translation bundles. There is no existing slot for this — a translations field is net-new on the plugin surface (packages/types/src/package.ts):
// packages/types/src/package.ts
export interface Plugin {
name: string;
version: string;
runes: Record<string, PluginRune>;
extends?: Record<string, RuneExtension>;
theme?: PluginThemeConfig;
behaviors?: Record<string, unknown>;
pipeline?: PluginPipelineHooks;
fileRoots?: Record<string, string>;
/** NEW: per-locale translation bundles, keyed by BCP 47 locale.
* Authored as JSON files imported here (Decision D3). */
translations?: Record<string, Record<string, LocalizedValue>>;
}
// plugins/learning/src/index.ts — JSON authored per locale, imported into the typed field
import de from './i18n/de.json';
import fr from './i18n/fr.json';
export const learningPlugin: Plugin = {
name: 'learning',
// ... existing fields ...
translations: { de, fr },
};
Merge path: mergePlugins() (packages/runes/src/plugins.ts) already aggregates plugin theme contributions (themeRunes, themeIcons, themeBackgrounds) into MergedPluginResult. Translation bundles follow the same pattern: thread a translations field through LoadedPlugin and MergedPluginResult, select the bundle for the configured locale (with the D5 tag fallback), and merge into the LocaleContext.strings. Site-level (ThemeConfig.strings) entries take precedence over plugin defaults per the D5 precedence chain.
Interaction with knownSections
knownSections has shipped, but without stable-slug support (see Zone 7). The locale integration is now a concrete follow-up on an existing type rather than a design against an unbuilt feature:
// packages/types/src/content-model.ts — proposed additions
export interface KnownSectionDefinition {
alias?: string[]; // exists today
model?: StructuralContentModel; // exists today
/** NEW: canonical slug for the section's data-name, independent of
* source language. Falls back to slugify(canonicalName) if omitted. */
canonicalSlug?: string;
/** NEW: per-locale heading aliases, matched in addition to `alias`
* when config.locale is set. */
i18nAliases?: Record<string, string[]>;
}
buildSections() (plugins/plan/src/util.ts) would derive the <section data-name> from canonicalSlug when a known section matched, and matchKnownSection() (packages/runes/src/lib/resolver.ts) would consult i18nAliases[config.locale] alongside the base alias list. This makes knownSections the 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, consultIntl.DurationFormat(or a polyfill) to produce"5 Std. 30 Min."in German. - Number formatting: Budget amounts use
Intl.NumberFormat(config.locale)for locale-appropriate separators. - Currency: Already partially handled by
BUDGET_CURRENCY_SYMBOLS;Intl.NumberFormatwithstyle: 'currency'would replace the manual symbol lookup.
Tooling — refrakt i18n extract
Auto-derived keys (Decision D1) are only usable if authors and translators can discover them, so the extract command is a first-class deliverable, not a convenience — it is the mitigation that makes auto-derivation acceptable:
- Walks every rune's structure /
metaFields(reusing thecontractsmachinery) and emits every derivable key with its English default as a JSON dictionary — the samekey → valueshape used for translation files (Decision D3), soextract → translate → commit de.jsonround-trips. --checkmode (mirroringcontracts --check) fails CI on drift: a new labelled field with no dictionary entry, or an orphaned key after a block rename. It also reports per-locale coverage ("de: 94%"), giving the first-party quality bar a measured number.- MCP-exposed following the
refrakt.contractsprecedent (CLAUDE.md prefers MCP over CLI where both exist, and an agent bootstrapping a translation wants structured key/default data more than parsed CLI text). Structured JSON output makes the wrapper thin; the detailed input schema is deferred to the work item.
(This does not conflict with the "CLI/developer tooling i18n" non-goal — that non-goal is about translating refrakt's own CLI strings, not about tooling that supports the i18n system.)
First-Party Translations
Because framework-generated chrome is identical on every site, refrakt ships first-party translations rather than leaving every site owner to re-translate the same ~120 strings (Decision D7):
- Phased after the mechanism. v1 ships the machinery + extract tooling + English; bundled languages land as a follow-on.
- Scope grows by demand. No fixed language list in this spec; a small initial tier, extended by community PR.
- Native-speaker reviewed before a language is advertised as supported — a bad machine translation under the refrakt name is worse than English. Partial coverage is safe (English fallback per key, per D5), but the quality bar gates the "supported" label.
- Packaged in-package, per locale, selected at build time (Decision D8). Each package that owns strings ships its own
i18n/<locale>.json: core strings inpackages/runes(alongsidecoreConfig), each plugin's strings in that plugin. Not one npm package per language — that would centralize distributed ownership (a per-language package would carry every scope's keys and re-release on any string change), and would split first-party (per-language packages) from community (in-package bundles) into two delivery models. The usual per-language-package driver — runtime client bundle-splitting — doesn't apply to a build-time generator that emits only the active locale. Fixed-mode Changesets versioning means a per-language package wouldn't even decouple releases. An optional aggregate convenience meta-package could re-export first-party bundles later if there's demand, but ownership stays in-package.
Implementation Zones and Priorities
| Priority | Zone / Item | Effort | Impact |
|---|---|---|---|
| P0 | ThemeConfig.locale + strings + LocaleContext + resolveLocaleString() / resolvePluralString() | Small | Foundation for everything else |
| P0 | Thread LocaleContext into buildStructureElement() + MetaField renderers | Medium | Prerequisite for Zone 1 — no config access today |
| P1 | Zone 1 (structure & meta-field labels) + auto-derived keys | 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 | ~14 strings, visible site-wide |
| P1 | refrakt i18n extract (+ --check, MCP) | Medium | Discoverability that makes auto-derived keys usable |
| P2 | Zone 5 (behaviors) — inline delivery (data-i18n-* + JSON block) | Medium | ~45 strings, client-side delivery mechanism |
| P2 | Zone 6 (enum display values) | Small | Hint titles, typography, docs symbol groups |
| P2 | Zone 2 (postTransform / plan render) | Small | Re-audit needed — pipeline moved to render model |
| P2 | Plugin translations bundle + mergePlugins() wiring | Medium | Enables community-plugin localization |
| P3 | Zone 7 (knownSections canonicalSlug + i18nAliases) | Small | Framework now exists — scoped follow-up |
| P3 | Zone 8 (cross-adapter lang attribute) | Small | Correctness/a11y across all adapters |
| P3 | First-party translation bundles (per-locale JSON, in-package) | Ongoing | Batteries-included localization; demand-driven |
| 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. (Distinct fromrefrakt i18n extract, which is tooling that supports the i18n system.) - AI prompt translation: The
packages/ai/prompts are English-only and used for content generation, not end-user display. - CSS-generated label text: Labels emitted via CSS
content:(e.g. diffBefore/After, trailing label colons) are a theming concern, not a string-table concern. - ICU MessageFormat: Deliberately not adopted (see Decision D2).
Forward Compatibility — Multi-Locale Sites
Serving the same site in multiple languages (route-based locale trees, a language switcher, Accept-Language auto-detect) stays a Non-Goal and is demand-driven — we build it only if there's real demand. But this design is deliberately constrained so that feature can be added as an additive layer, without breaking changes. The constraints below are load-bearing; violating them would turn a future additive feature into a rewrite.
Constraints this spec must hold to keep the door open:
- Locale is render-scoped context, never module-global mutable state. The active locale lives in the
LocaleContextthreaded through resolution (Decision D6) — not a singleton set once per process. This is the single most important constraint: a future route-based system may build multiple locale subtrees, potentially within one build, and any module-level "current locale" global would make that impossible without a rewrite. D6's threaded slice already satisfies this; do not shortcut it into a global. - Keys stay locale-independent.
{scope}.{block}.{ref}never encodes a locale, so the same key space resolves against any locale's bundle. A multi-locale build reuses identical keys per subtree. - Per-page locale markers are already emitted.
<html lang>(Zone 8) and<meta name="rf-locale">are per-page, and behavior strings are delivered inline per page (Decision D4) — so N locale subtrees each carry correct, self-contained locale state with no shared single-locale assumption.hreflangalternate-links are the clean additive extension point on top. canonicalSlug(Zone 7) is the cross-locale URL/anchor stabilizer. Language-independent section slugs mean anchors, deep links, and CSS selectors stay identical across locale trees (#acceptance-criteriain every language, not#akzeptanzkriterien). This is why Zone 7 matters beyond CSS stability — it's a prerequisite for portable URLs in a multi-locale site.
What is explicitly deferred to that future feature (and sits on top of this foundation, not inside it): route layout (/en/…, /de/…), parallel/translated content trees, the switcher component, per-locale sitemaps, and any auto-detect redirect layer. None of these require changing the string-resolution model defined here.
Decisions
Resolved from the v1.0 / v2.0 open questions:
- D1 — Key derivation: Auto-derive keys from the config path (
{plugin}.{block}.{ref}), with an optional expliciti18nKeyoverride on a field for stability across refactors. Discoverability is handled by therefrakt i18n extracttooling, not by verbose per-label keys. Rationale: ~68+ labels make explicit keys pure, drift-prone boilerplate; auto-derivation keeps keys correct by construction and covers both label paths uniformly; the extract command turns discoverability into a generated, exhaustive artifact. - D2 — Plurals:
Intl.PluralRules+PluralMapdictionary values, plus a preference for count-neutral phrasing. No Englishn === 1ternaries; no ICU MessageFormat engine. Rationale: hardcoded binary plurals are an i18n bug in most languages; full ICU is priced for variable-laden sentence-prose at scale, which refrakt's atomic-label / decomposed-metaField architecture largely designs away;Intl.PluralRulesgives the identical CLDR model at zero dependency. Revisit only on variable-laden chrome or a TMS workflow — the resolver's shape-dispatch keeps that door open without a migration. - D3 — Format: JSON, one file per locale, values
string | PluralMap, imported into the typedtranslationsfield; inline TS allowed but JSON is canonical. Rationale: JSON is what translators and TMS tools consume; a typed JSON import keeps type safety; it round-trips with extract output. - D4 — Behavior delivery: Inline
<script type="application/json" id="rf-strings">per page +data-i18n-*attributes for element-attached strings; the global block carries only runtime-created strings. No fetched/rf-strings.jsonendpoint. Rationale: the payload is a couple KB; inline gives synchronous init (no flash-of-English), keeps pages self-contained, and avoids per-adapter routing / cache-invalidation. Revisit only if the string set grows large or soft-nav duplication becomes measurable. - D5 — Fallback: Per-key first-match — site
strings→ owning package's shipped locale bundle (withde-AT→detag fallback) → English floor. No per-plugin default language. Per-key degradation. Rationale: English is always present as the universal floor; a surprise third language is worse than English; per-key degradation makes partial bundles safe. - D6 — Config threading: A narrow
LocaleContext { locale, strings }slice intobuildStructureElement()and the MetaField renderers, not the fullThemeConfig. A boundt()closure is an acceptable alternative at implementation time. Rationale: matches the engine's existing convention of passing explicit slices (icons,modifierValues); plumbing cost is identical either way, so minimal coupling wins; narrow-first is the safe direction to widen later. - D7 — First-party translations: Ship them — phased after the mechanism, native-speaker-reviewed, English-fallback for partial coverage, coverage measured by the extract
--check. Rationale: chrome strings are universal, so translating them once is the framework's job; it's the difference between "i18n is possible" and "i18n is batteries-included." - D8 — Packaging: In-package per-locale JSON, locale selected at build time; not per-language npm packages. Rationale: matches string ownership, unifies first-party and community delivery, and the runtime-bundle-splitting driver for per-language packages doesn't apply to a build-time generator.