中文中文版本覆盖全站页面;博文与 CHANGELOG 归档以英文原文发布。
Open navigation

Internationalization

Locale-prefixed content files and build-time message catalogs — an application convention, deliberately outside framework core.

Applies to v1.0.0-alpha.5 · Updated

Where i18n lives

Internationalization is an application concern, not a framework one. The router ships routing, render context and document ownership; it ships no message catalog, no locale negotiator and no plural/date formatting. That boundary is deliberate (decision D6, #1383): message catalogs are a build-time data pipeline with real ecosystem options, and a framework-owned one would either freeze a format or grow a second one.

Two conventions carry the whole job, and both are things you can build with what the framework already gives you:

  • Locale-prefixed files for content that a route serves as a document.
  • Message catalogs for strings that components interpolate.

Locale-prefixed file conventions

Declare the locales the build expands, then keep one file per locale:

// openelement.config.ts
import { openElement } from '@openelement/router/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    openElement({
      i18n: {
        // Prefixes the build expands; the first entry is the default locale.
        locales: ['en', 'zh'],
        defaultLocale: 'en',
      },
    }),
  ],
});

With that declaration, /guide/api is the default locale and /zh/guide/api is the Chinese copy. The build renders each route once per non-default locale into its prefixed path, and PagePropsContext.locale carries the locale that resolved for the render, so a props projector can select the right record:

import { definePage, type PagePropsContext } from '@openelement/router';
import Page from './page.tsx';

const records = {
  en: { title: 'API routes', body: 'Applications own request-time handlers.' },
  zh: { title: 'API 路由', body: '请求时处理器由应用自己拥有。' },
} as const;

function projectProps(context: PagePropsContext) {
  // The router resolves the locale from the request path; the record lookup
  // is application policy.
  const locale = context.locale === 'zh' ? 'zh' : 'en';
  return records[locale];
}

export default definePage(Page, { props: projectProps });

File naming is the other half of the convention, and it is your loader's, not the router's: this site keeps api.md and api.zh.md side by side and its collection loader resolves the suffix into a locale field (www/content-collections.ts). The suffix scheme beats a directory scheme when translators edit in the same folder; a directory scheme (content/zh/api.md) beats it when the trees are reviewed separately. Either way the router only needs one thing — that a route exists per locale — which the i18n declaration above already provides for statically rendered pages.

Three rules keep the two halves honest:

  • Link through one helper. Every internal link must gain the active locale's prefix. A bare /guide/api in zh content drops the reader into English. Centralize the prefixing (stripLocalePrefix / a localizePath sibling) so no page rewrites it by hand.
  • Never infer the locale from a bare two-letter segment. Recognize the declared locales explicitly; /ui/... and /go/... are sections, not locales.
  • Keep the default locale unprefixed. Two URLs for one page split its search ranking and its analytics; the unprefixed path stays canonical.

Message catalogs

Component-interpolated strings need a catalog, and the boring standard is the right answer. Keep the catalog build-time: a static-first framework prerenders its pages, so the catalog can be compiled into each locale bundle instead of fetched at runtime. Two shapes fit:

  • ICU MessageFormat (npm:intl-messageformat) — the format most translation tools already read and write. Messages carry their own plurals, selects and placeholder typing, and the parser is a small dependency that only the localized bundle pulls in.
  • paraglide — a compiler that turns message functions into plain per-locale modules, so unused messages tree-shake out of the bundle and each call site is type-checked against the catalog. Its static-first posture matches a prerendered site, which is why it is the recommended default here when translation volume grows.

Neither belongs in a component. One module owns lookup and formatting, and components call it:

// app/i18n/messages.ts — application code, not framework code.
const catalogs = {
  en: {
    'cart.items': '{count, plural, one {# item} other {# items}}',
    'cart.total': 'Total: {amount}',
  },
  zh: {
    'cart.items': '{count, plural, other {# 件商品}}',
    'cart.total': '合计:{amount}',
  },
} as const;

export function messages(locale: string) {
  return locale === 'zh' ? catalogs.zh : catalogs.en;
}
// app/components/cart-summary.tsx (excerpt)
import { element, OpenElement, property } from '@openelement/element';
import { messages } from '../i18n/messages.ts';

@element('cart-summary')
export default class CartSummary extends OpenElement {
  @property({ reflect: false })
  locale = 'en';

  @property({ reflect: false, type: Number, attribute: false })
  count = 0;

  render() {
    const copy = messages(this.locale);
    return <p>{copy['cart.items'].replace('{count}', String(this.count))}</p>;
  }
}

The {count, plural, ...} string above is the message source the formatter consumes; the excerpt shows the shape of the call site, and the real implementation passes the raw pattern and its arguments to the compiled formatter (or to the paraglide-generated function) rather than replacing placeholders by hand.

What the framework does contribute is the locale's arrival: PagePropsContext.locale for server-rendered and prerendered pages, and the same value on an island through an explicit property — an island never reads the URL itself, so a locale change has one owner.

What stays out of the framework

The boundary is the same one MDX draws for content pipelines. The framework will not:

  • ship a message format, catalog loader or plural rules — pick the ecosystem one that fits;
  • negotiate Accept-Language for you — locale resolution from the path is routing; content negotiation is application policy, and a request-time route can do it in a loader;
  • translate your content files — a suffix or directory convention is a loader decision.

If you find yourself wanting framework-owned catalog state, the answer is usually the same as for any other shared state: a signal or a context on your own module, read through a props projector or an island property.

See also

  • Configuration — where the i18n declaration and route options live.
  • Routing and Data — how a locale prefix becomes route params, loaders and props.
  • MDX — the content-pipeline precedent this guide applies to catalogs.