Skip to content

Localization

Edgar Mesquita edited this page Aug 15, 2026 · 15 revisions

Localization

🌐 This page in: English · Português

Multi-language in eQuantic.UI follows one law: the developer localizes with what .NET itself offers: .resx, the strongly-typed accessor, CultureInfo. It never sees a JavaScript catalog or a framework DSL. The full design (Track L: how eqc bridges ResourceManager into the browser, per-culture catalogs, the formatting subset, the honest fences) lives in the repo at docs/I18N-PLAN.md. This page documents what is shipped today and what is still design.

Shipped

The SDK's own strings live behind one seam

Since 0.2.0-preview.24

Components never hardcode a user-facing string. Every built-in label (the toggle announcements a screen reader hears ("Checked", "On"), the dismiss affordances, the search placeholder, the code editor's find bar) reads SdkStrings, a static class of properties (never consts: a const crosses assemblies by VALUE, and inlined call sites would keep announcing the build machine's language after the seam learns cultures). The transpiled twins confirm the choke point survives on the web too: eqc emits static getters and the components reference them. When Track L lands, the property bodies swap to the resx-backed accessor and no component changes.

Photon windows adopt the machine's locale at launch

Since 0.2.0-preview.24

A GUI process launched from Finder carries no LANG/LC_*, so .NET starts invariant even on a pt-BR machine. The shells resolve the platform's locale truth before the first frame: NSLocale.preferredLanguages[0] as the UI culture and currentLocale as the format culture on Apple (the same split .NET models as CurrentUICulture vs CurrentCulture), Locale.getDefault() feeding both on Android, and PhotonCultureController copies the pair onto the process statics, repainting on a later Apply exactly as the theme controller repaints on a mode flip. Because Photon runs real .NET with no transpilation, ResourceManager and satellite assemblies already work there: a native app localizes today with ordinary resx.

A page speaks resx on the web, end to end

Since 0.2.0-preview.26

Track L's M0: an app writes localization exactly as any .NET app does: a Resources/Strings.resx plus Strings.pt-BR.resx, the ordinary Designer accessors, string.Format(Strings.Greeting, name), and the page answers in the request's culture on BOTH halves of the web target.

sequenceDiagram
  participant B as Browser
  participant S as Server
  participant R as Runtime
  B->>S: GET /page + Accept-Language
  S->>S: pick catalog (exact, parent, neutral)
  S-->>B: shell + window.__EQ_CULTURE__
  B->>R: boot installs culture
  R->>R: hydrate with the SAME strings
Loading

The pieces, mirroring the theme bridge shape for shape:

  • eqc rewrites accessors, never inlines them. Strings.Hero_Title compiles to $eq.str("Strings", "Hero.Title"), because inlining would bake the build machine's culture into the bundle. Detection is by SHAPE (the Designer's static ResourceManager + Culture properties), so any resx anywhere in the project works, whatever the developer named it.
  • The build emits catalogs from the keys the app actually uses. wwwroot/_equantic/strings/neutral.json plus one {culture}.json per authored variant, each FLATTENED against the .NET fallback chain at emit time: the server picks a file, it never merges.
  • The server inlines the answer. UseRequestLocalization (the APP wires negotiation, the SDK only reads what the middleware set) drives <html lang> and a window.__EQ_CULTURE__ = { name, formatName, strings } shell slot; boot installs it BEFORE hydration, so the client resolves exactly the strings the server rendered.
  • string.Format over an accessor is validated at build. EQ2100 rejects non-string arguments, format specifiers and arity drift against the neutral resx, the M0 subset of the plan's D7.

Switching language re-renders, it never reloads

Since 0.2.0-preview.27

ICultureController is the app's hand on the LANGUAGE, in the IThemeController shape, resolved by interface name, so a component offering a switcher never learns which target answered. It speaks BCP-47 names, never CultureInfo: the contract crosses to a browser where no such type exists, and a name is the one currency .NET, the web and both mobile platforms share.

  • Web: setCulture fetches the culture's catalog only when it is not already in memory, picking the file the way the SERVER picks it (exact → parents → neutral; the chain was flattened at build, so the lookup stays flat), swaps it, and re-renders through the same scheduler SetState uses. State survives; only changed text is touched. The switch also outlives the page: the controller writes ASP.NET's own culture cookie, in ASP.NET's own format, so the next request arrives already translated.
  • Native: PhotonCultureController realizes the same interface: the process statics change and the window repaints.
  • CultureSwitcher is the built-in surface: a segmented control for two or three languages, a menu beyond that, lighting the active option by exact name then by language part (a page served as pt-BR must light a pt entry).
new CultureSwitcher([
    new("en", "English"),
    new("pt-BR", "Português"),
    new("es", "Español"),
])

A format specifier survives the trip

Since 0.2.0-preview.27

{0:C2} written once in C# prints R$ 1.234,50 on a pt-BR page and 1234,50 € on an es one with SSR and client agreeing character for character, and a culture switch re-formats live with the strings. The D7 subset: N/F/P/C/D/E with precision, the standard date/time letters, custom digit and date pictures, and the bare {0} (which is NOT invariant in .NET, and now is not in the browser either).

The proof is the method: the pinned fixture is generated from real .NET (value.ToString(spec, culture), three cultures chosen to disagree) and the transpiled runtime must reproduce it exactly. That direction caught every divergence at build time. Intl rounds midpoints away from zero where .NET banks them, so the formatter pre-rounds with the banker's helper; Intl's presets drop en-US's short date to a two-digit year, so each culture's own patterns travel in its catalog as $-facts beside the strings, with the ISO currency code (Intl demands a code, and no browser API derives one from a locale).

What cannot agree is refused at build, never approximated: EQ2100 rejects alignment and out-of-subset specifiers in a resx template; EQ2101 holds every translation's placeholders against the neutral resx, so a pt-BR string asking for {2} where neutral has {0}/{1} fails the build instead of throwing for Brazilian readers only.

Scope, stated plainly: the exact-agreement promise covers the cultures the app SHIPS. A culture negotiated at runtime with no authored catalog degrades to neutral strings and neutral (invariant) formats, deterministic and never browser-dependent.

Two cultures in one window

Since 0.2.0-preview.29

The web half of this story is a request: one culture in, SSR and hydration agreeing. A window has no request, so a switch there has three joints the web never has: the platform decides the initial locale, someone copies it onto .NET's statics, and the tree has to REBUILD for the change to reach pixels.

A component says none of that. The Studio sample's Language section is an ordinary CultureSwitcher, which resolves ICultureController from the context and names no platform... the same class the browser renders. Applying a culture writes both statics (D13's pair: resources and formats are separate decisions) and repaints the window, so the switch lands on the next frame with every bit of page state intact.

The screenshot path renders it both ways, which is what closed the milestone: with no argument the window shows the MACHINE's pair (an English interface with Portuguese formats, on a Mac set that way), and with --culture pt-BR it shows Marcado, Ativado, Planilha beside R$ 1.234,50.

Writing the sample found the bug the tests could not: the host registered only the concrete controller, so a component asking for the INTERFACE resolved null and the switcher switched nothing. Two registrations, one instance. A capability nobody resolves in a real window is a capability that does not work.

A new app is born localized

Since 0.2.0-preview.28

dotnet new equantic-app now scaffolds what this page preaches: a Resources/Strings.resx with its pt-BR satellite and the ordinary Designer accessor, a CultureSwitcher on the home page, and the whole story in one line, string.Format(Strings.CountedTimes, _count). A developer's first project answers in two languages before they have written any code, which is the cheapest moment to learn that UI strings never get hardcoded. See Getting Started for the full scaffold.

A crawler is told which URL is which language

Since 0.2.0-preview.31

Translating a site and never declaring the translation group is how a localized site quietly ranks against itself. One app-wide policy — options.UseAlternateLinks(AlternateUrls.PathPrefix(), "en", "pt-BR", "es") — puts the whole rel="alternate" hreflang set plus x-default in every page's head, with the reciprocity and absolute-URL rules enforced rather than left to the app. See Server Integration.

Still design (Track L, docs/I18N-PLAN.md)

Satellites under NativeAOT are no longer a question: an executable harness (scripts/verify-aot-satellites.sh) publishes a native binary against the component library and refuses to pass unless en/pt-BR/es all answer with the TRANSLATION, because a dropped satellite does not crash, it silently answers English, so the check compares against expected translations.

Still ahead: per-page catalog splitting, plural forms beyond two, and IHandleMetadata re-running on a culture switch. RTL and per-script font coverage are explicitly other tracks.

Clone this wiki locally