Skip to content

Internationalization

Internationalization is more than replacing English strings. A translated terminal application must resolve the right catalog, preserve message meaning, measure the rendered labels in cells, keep keyboard accelerators usable, and recover safely when external catalog data is incomplete. This course builds that complete workflow.

Who is this course for?

This course is for application developers who need non-English framework labels, application-owned copy, locale formatting, or runtime catalog sources. You should already be comfortable with the application shell and with Unicode and terminal-cell geometry. No prior localization library experience is required.

You will progress through three boundaries:

LevelBoundary
BeginnerDefine a catalog, create one locale-bound service, translate messages, and inject it into an application.
IntermediateCompose framework and application catalogs, validate and load external data, format values, and measure translated controls.
AdvancedReconstruct an application for a locale switch, diagnose fallback and source failures, enforce trust boundaries, and run multilingual QA.

After the course you will be able to build a localized application, explain its lookup and ownership model, diagnose catalog and layout failures, and verify every supported locale at normal and constrained terminal sizes. The motivating problem is a settings application whose short English actions become long German actions while an optional product catalog can fail to load. Both the words and the geometry must remain correct.

If an application needs only the built-in English framework copy, keep the no-config service. Adding internationalization machinery without a product need creates catalog and testing work but does not improve that application.

What is the internationalization mental model?

One locale-bound I18n service owns message lookup and Intl formatters. The application owns that service and passes the same instance to createApplication({ i18n }) and to package APIs that accept it. There is no global locale registry and no service per control.

text
requested locale


locale catalog → base-language catalog → configured fallbacks → English


message evaluation + locale formatting


fresh translated controls → measured terminal-cell layout

Lookup and formatting have related but different locale rules:

  • The requested locale, such as nl-BE-u-nu-latn, is retained for Intl formatting.
  • Catalog lookup removes formatting extensions, tries the requested locale, then its base language, then each fallbackLocales entry and its base language, and finally English.
  • Catalog layers are ordered. For the same locale and key, later catalogs win.
  • A translation call captures one immutable catalog snapshot. setCatalog publishes a complete replacement only after it validates and compiles.
  • Locale is construction state. Switching from English to German creates a fresh service and a fresh translated application tree; it does not mutate already-constructed controls.

This ownership boundary explains why strings, accelerators, formatters, and measured geometry stay coherent: they all come from the same locale generation.

How do I produce the first translated result?

Install @jsvision/i18n alongside the UI packages whose catalogs you use. The main entry point is browser-safe and ESM-only. Node catalog-file loading is deliberately isolated in @jsvision/i18n/node.

The smallest useful result defines application copy, creates one service, and translates one key:

ts
import { createI18n, defineCatalog } from '@jsvision/i18n';

const appNl = defineCatalog({
  schema: 1,
  locale: 'nl',
  messages: {
    'app.greeting': 'Hallo ${name}',
  },
});

const i18n = createI18n({ locale: 'nl', catalogs: [appNl] });
const greeting = i18n.t('app.greeting', { params: { name: 'Ada' } });

Catalog keys are namespaced so independently developed packages do not collide. Pass parameters as safe primitives; the service never invokes arbitrary caller coercion. Inject the exact service into the application:

ts
import { createApplication } from '@jsvision/ui';

const app = createApplication({ i18n });

The returned app.i18n is the same instance. Pass it to Forms, Files, Data Grid, CodeEditor, and CodeEditorWindow APIs that accept internationalization. Framework chrome is translated; caller-owned filenames, source code, queries, replacements, language IDs, paths, command tokens, and protocol details remain unchanged.

Laboratory: catalog lookup and safe publication

The first laboratory requests nl-BE, resolves Dutch application copy, falls back to English for structured messages, records a missing translation without values, rejects unsafe catalog text, and then publishes a valid same-locale overlay atomically.

How do catalogs, fallbacks, and messages compose?

Import only the requested locale from each package. There is intentionally no all-locales bundle:

ts
import { codeEditorNl } from '@jsvision/code-editor/locales/nl';
import { datagridNl } from '@jsvision/datagrid/locales/nl';
import { filesNl } from '@jsvision/files/locales/nl';
import { formsNl } from '@jsvision/forms/locales/nl';
import { uiNl } from '@jsvision/ui/locales/nl';

const i18n = createI18n({
  locale: 'nl-BE',
  fallbackLocales: ['de'],
  catalogs: [uiNl, formsNl, filesNl, datagridNl, codeEditorNl, appNl],
});

Put framework catalogs first and the application catalog last. Because later same-locale catalog layers win, application-owned overrides are visible in composition rather than hidden in a global mutation. availableLocales reports the sorted catalog locales; has(key) checks the configured lookup chain without producing a missing-key diagnostic.

Use strings for ordinary messages and structured messages when selection is semantic:

ts
import { defineCatalog, plural, select } from '@jsvision/i18n';

const appCatalog = defineCatalog({
  schema: 1,
  locale: 'en',
  messages: {
    'app.files': plural('count', {
      one: '${count} file',
      other: '${count} files',
    }),
    'app.account': select('state', {
      active: 'Active',
      other: 'Unknown',
    }),
    'app.placeholder-help': 'Show the literal $${name}.',
  },
});

plural uses cardinal Intl.PluralRules for the locale of the resolved message. Always provide other; add one, few, many, or other categories only when the locale uses them. select matches the controller's exact safe primitive string and also requires other. $${name} is the escape form: it renders the literal ${name} instead of interpolating it.

When a key cannot be found, t records MISSING_TRANSLATION and returns the call-site defaultMessage or, if none exists, the key. A missing or invalid parameter preserves the unresolved placeholder and records a diagnostic; it never guesses a value.

How do I validate and load untrusted catalogs?

defineCatalog validates, deep-copies, freezes, and returns one safe catalog or throws an I18nError. Use it for trusted authored data. validateCatalog returns sorted structural issues without throwing, which is useful at import and CI boundaries.

Validation has two completeness modes:

ModeUseWhat it proves
partialApplication overlaysEvery supplied key and message is safe and structurally valid; omitted keys are allowed.
strictComplete official catalogsKey parity, placeholder parity, and co-visible accelerator rules agree with the reference.
ts
import { validateCatalog } from '@jsvision/i18n';

const issues = validateCatalog(candidate, {
  mode: 'strict',
  referenceCatalog: english,
  placeholderManifest,
  acceleratorManifest,
  official: true,
  source: 'catalog-review',
});

Strict validation needs a referenceCatalog or reference keys. A placeholderManifest states the required parameter names per key. An acceleratorManifest groups co-visible labels so missing, malformed, or duplicate accelerators are detected. With official: true, accelerator warnings become blocking errors.

loadI18n coordinates external sources and publishes one atomic service. A required source (required: true, the default) blocks publication when it fails. An optional source (required: false) contributes a value-free SOURCE_FAILED diagnostic while successful results still publish. All source results validate before atomic publication.

ts
import { loadI18n } from '@jsvision/i18n';

const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]);

const i18n = await loadI18n({
  locale: 'nl',
  catalogs: [uiNl],
  signal,
  sources: [
    {
      name: 'application-catalog',
      required: true,
      async load({ signal: sourceSignal }) {
        const response = await fetch('/locales/nl.json', { signal: sourceSignal });
        if (!response.ok) throw new Error('Catalog request failed');
        return response.json();
      },
    },
  ],
});

The application owns network authentication or authorization, retry, timeout, and cache policy. The source receives the caller-owned cancellation signal. Do not log response bodies or translation values on failure. In Node composition code, jsonFileSource from @jsvision/i18n/node implements the same source contract; never import it into a browser bundle.

How do I switch locale safely?

I18n.locale is readonly. There is no setLocale method because controls commonly capture translated labels, accelerator markup, natural widths, and subscriptions during construction. Changing only the service would leave a mixed-generation tree.

Treat a locale switch as application reconstruction:

To switch locale safely, create a fresh I18n, create a fresh Application, publish the new generation, and dispose the previous tree.

ts
import { createI18n } from '@jsvision/i18n';
import { createApplication } from '@jsvision/ui';
import type { Application } from '@jsvision/ui';

interface LocalizedApplicationSlot {
  // Atomically redirect input and return the detached previous generation.
  replace(next: Application): Application;
}

function buildLocalizedApplication(locale: 'en' | 'de') {
  const i18n = createI18n({
    locale,
    catalogs: catalogsFor(locale),
  });
  return createApplication({ i18n });
}

function switchLocale(slot: LocalizedApplicationSlot, locale: 'en' | 'de') {
  const next = buildLocalizedApplication(locale);
  let previous: Application;
  try {
    previous = slot.replace(next);
  } catch (cause) {
    next.loop.dispose();
    throw cause;
  }
  previous.loop.dispose();
  return next;
}

LocalizedApplicationSlot is an application-owned host seam, not a JSVision export. Its replace operation synchronously redirects input to the ready generation and returns the now-detached previous application without disposing it. If replacement throws, the current generation remains active and the unpublished candidate is disposed. After success, dispose the detached tree exactly once outside the replacement rollback block. If an old-tree cleanup throws, surface that exception for diagnostics while leaving the already-published next generation active. Transfer only application-owned model state before this handoff.

setCatalog solves a different problem: it atomically replaces the highest-priority runtime overlay for the same locale. The complete overlay validates before publication; if validation throws, existing readers retain the previous catalog. Use it for corrected copy or a product overlay, not for changing locale.

Laboratory: locale reconstruction and translated geometry

This laboratory reconstructs a representative translated subtree with a fresh English or German service. It then measures the complete action group after translation, including long captions, wide text, combining text, and accelerator markup. In production, apply the same rule to the complete application tree.

How do translated layouts stay usable?

Never estimate translated labels with JavaScript .length. Terminal display-cell geometry counts a wide glyph as two cells, a combining sequence as one, and accelerator markup such as ~X~ as non-rendered syntax; JavaScript .length measures none of those correctly.

Construct all co-visible actions first, then measure and compose the complete logical group:

ts
import { Button, buttonGroup, measureButtonGroup } from '@jsvision/ui';

const actions = [new Button(i18n.t('app.action.save')), new Button(i18n.t('app.action.cancel'))];
const options = { minimumButtonWidth: 10, gap: 2 } as const;
const metrics = measureButtonGroup(actions, options);
const actionBand = buttonGroup(actions, options);

Size the containing surface to at least metrics.width. Use maxColumns for stable row-major wrapping or buttonColumn for a vertical action rail. Measure the full group once, including buttons that a constrained viewport places on another row. Each live Button has one parent; do not measure one set and attach a duplicate set with different captions.

Long labels are not an edge case. Test the longest supported caption in every viewport strategy:

  • At the normal viewport, leave readable separation and preserve visible focus.
  • At a constrained viewport, wrap actions, use a vertical rail, allow a workspace to scroll, or negotiate a larger minimum. The terminal's final hard bound may still clip; report that limit honestly.
  • After resize, maximize, and restore, recompute container geometry while preserving authored label and instruction heights.
  • In monochrome and ASCII-safe capability profiles, keep state and commands understandable without relying on colour or decorative glyphs.

Read the Layout course for flow and overlay ownership. Individual control sizing stays with the relevant component courses.

How do formatting, diagnostics, and trust boundaries work?

The service owns bounded locale-specific formatter caches:

ts
i18n.number(12_345.67, { style: 'currency', currency: 'EUR' });
i18n.date(Date.UTC(2026, 6, 30), { dateStyle: 'long', timeZone: 'UTC' });
i18n.compare('Äpfel', 'Zitrone', { sensitivity: 'base' });

number accepts a finite number or bigint; date accepts a valid Date or finite epoch milliseconds; compare normalizes safe strings to NFC before locale collation. Invalid values and formatter options throw typed I18nError failures instead of silently formatting misleading data.

Recoverable translation faults are different. Missing translations, parameters, and controllers return safe fallback text and add a diagnostic. The store is bounded to 100 records and deduplicated. Diagnostics are value-free: they identify code, key, locale, severity, and optional source, but never capture parameter values or translated text. A diagnosticSink observes each new record; keep the sink similarly value-free.

Catalog inputs are untrusted at every publication boundary. Validation rejects malformed Unicode, terminal control sequences, unsafe bidi controls, invalid keys, excessive catalog work, and malformed placeholders. This protects the terminal stream, but it does not authorize a network request or filesystem path. The host must still enforce origin, credentials, path confinement, response-size, timeout, and cancellation policy.

How do I test every supported locale?

Run the dedicated interactive harness after catalog or translated-layout changes:

bash
yarn workspace @jsvision/examples demo:i18n

Its typed story registry covers en, nl, de, fr, es, it, pt-PT, pl, ro, sv. Each story composes matching imports from @jsvision/ui/locales/, @jsvision/forms/locales/, @jsvision/files/locales/, @jsvision/datagrid/locales/, and @jsvision/code-editor/locales/. Deliberately long caption overrides expose guessed geometry.

Every locale/story selection creates a fresh I18n and a fresh Application; it never mutates locale state under existing controls. Test at least:

DimensionEvidence
Message meaningPlain, interpolated, plural, select, literal placeholder, and fallback results.
Catalog integrityPartial overlays plus strict reference, placeholder, and accelerator validation.
GeometryNormal and constrained viewports, longest labels, wide and combining text, resize, maximize, and restore.
InputKeyboard reachability, unique co-visible accelerators, mouse parity, and visible focus.
CapabilitiesTrue colour, monochrome, and ASCII-safe fallback without colour-only meaning.
FailureMissing key/parameter, invalid catalog, required and optional source failure, abort, and stale reconstruction.

Caller-owned content is not translated. Preserve filenames, source and query text, replacement text, language IDs, and protocol details exactly as supplied while testing the surrounding chrome.

How do I diagnose internationalization failures?

Use observable evidence to distinguish similar symptoms:

SymptomLikely causeCorrectionDistinguishing evidence
A key is printed literallyMissing translation and no call-site defaultAdd the key to the intended locale/fallback catalog or provide a safe defaultMISSING_TRANSLATION names the key and requested locale.
${name} remains visibleMissing parameter or invalid primitive valuePass the exact named safe parameterMISSING_PARAMETER identifies the message without recording its value.
Catalog publication throwsInvalid catalog, unsafe text, schema mismatch, or strict parity errorInspect sorted issues, correct the source, and retry with a complete candidateThe previous service or overlay remains readable.
Startup never publishesRequired source failed or loading was abortedFix the source/authorization or retry under a fresh caller-owned signalSOURCE_FAILED rejects; ABORTED distinguishes cancellation.
Optional copy falls backOptional source failedKeep the fallback visible, record the safe source identity, and retry by policyThe service publishes with a value-free SOURCE_FAILED diagnostic.
Buttons are clippedLong translated labels were sized with constants or .lengthMeasure real controls, wrap/stack at constrained widths, and retestThe frame fits but a child crosses its padded content bounds.
Labels and accelerators disagreeLocale changed under an existing treeBuild a fresh service and application, publish once, then dispose the stale treeMixed-language controls or old focus/subscription ownership remain mounted.
Sorting differs from display localeData used code-point order or a different formatter localeUse the shared service's compare with explicit optionsA minimal locale fixture reproduces the ordering difference.

Redact diagnostics before exporting them from the process. A catalog source name should be a stable, non-sensitive identity, never a URL with credentials or a user path.

What are the best practices?

  • Create one explicit locale-bound service per application generation. Multiple services let framework and application copy disagree.
  • Import one requested framework locale from each package. An all-locales bundle wastes browser payload and hides the supported-locale contract.
  • Put framework catalogs first and the application catalog last. This makes deliberate overrides reviewable.
  • Use namespaced keys and named safe parameters. Positional conventions and implicit coercion make translation review and diagnostics fragile.
  • Validate application overlays in partial mode and complete official catalogs in strict mode. Applying strict parity to an overlay rejects valid sparse customization; applying partial mode to an official catalog misses omissions.
  • Acquire and clean up together: abort owned loads, dispose the old application after locale publication, and scope the constructor-owned diagnostic sink closure and its captured resources to that service generation.
  • Measure translated controls after construction. Constants and .length fail for long, wide, combining, and accelerator-marked captions.
  • Keep failures value-free and bound external work. Logging translation values, source bodies, or parameter data can disclose user or product information.
  • Treat keyboard reachability, non-colour state, constrained geometry, monochrome, and ASCII-safe fallback as requirements throughout the design.

What should I practice next?

Try these exercises:

  1. Add a French catalog to the first laboratory with one plural, one select, and an escaped literal placeholder. Predict the lookup chain before running it.
  2. Create an incomplete official catalog. Compare partial validation with strict validation using a reference, placeholder manifest, and two co-visible accelerator labels.
  3. Add a deliberately long Polish action to the layout laboratory. Choose row wrapping or a vertical rail, then verify 80×24, a constrained viewport, maximize, and restore.
  4. Build an optional in-memory CatalogSource that fails once, succeeds on retry, and honors an AbortSignal. Confirm that its diagnostic contains no returned data.
  5. Write a locale-switch host test that publishes a fresh application, disposes the old loop once, and proves no stale command reaches the previous tree.

Continue with The application shell, Text, Unicode & terminal cells, and the Localized Theme Designer. Use the internationalization reference for supported entry points and evidence, then consult the generated @jsvision/i18n API and @jsvision/i18n/node API for exhaustive signatures.

When migrating from BlendSDK, replace tuple plurals with plural(parameter, cases), replace implicit string coercion with named params, and move filesystem loading to @jsvision/i18n/node. JSVision owns this implementation; @blendsdk/i18n is not a runtime dependency. Adapted concepts remain attributed in THIRD_PARTY_NOTICES.md.