SPEC-035
ID:SPEC-035Status:draft

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.

v1.0

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 via capitalize transform 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

  1. Zero-config English: Existing sites work unchanged. English is the fallback when no locale is configured.
  2. Additive localization: Adding a language means providing a strings dictionary — no structural changes to rune configs.
  3. Package-scoped translations: Community packages ship their own translations alongside their rune configs.
  4. Single resolution path: All localizable text resolves through one mechanism, whether it originates from structure labels, computed transforms, or behaviors.
  5. 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:

  1. 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.

  2. <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.

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 consults Intl.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.NumberFormat with style: '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

PriorityZoneEffortImpact
P0ThemeConfig.locale + strings + resolveString()SmallFoundation for everything else
P1Zone 1 (structure labels)MediumHighest visibility — affects all runes with metadata
P1Zone 4 (computed transforms)Small4 strings, highly visible on every page
P1Zone 3 (layout chrome)Small~12 strings, visible site-wide
P2Zone 5 (behaviors)Medium~40 strings, requires client-side delivery mechanism
P2Zone 6 (enum display values)SmallHint titles, diff headers — common runes
P2Zone 2 (postTransform text)SmallBudget and plan pipeline — fewer sites affected
P3Zone 7 (knownSections i18n)BlockedDepends on knownSections framework shipping first
P3Number/duration formattingSmallIntl 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

  1. 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.

  2. 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?

  3. 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?

  4. 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?

  5. 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?