diff --git a/.github/workflows/dispatch_release.yml b/.github/workflows/dispatch_release.yml index 65f3826c84c..92dee7e0319 100644 --- a/.github/workflows/dispatch_release.yml +++ b/.github/workflows/dispatch_release.yml @@ -147,6 +147,10 @@ on: description: "reflex-hosting-cli" type: boolean default: false + reflex_i18n: + description: "reflex-i18n" + type: boolean + default: false reflex_otel: description: "reflex-otel" type: boolean @@ -209,6 +213,7 @@ jobs: ${{ inputs.reflex_components_sonner && 'reflex-components-sonner' || '' }} ${{ inputs.reflex_docgen && 'reflex-docgen' || '' }} ${{ inputs.reflex_hosting_cli && 'reflex-hosting-cli' || '' }} + ${{ inputs.reflex_i18n && 'reflex-i18n' || '' }} ${{ inputs.reflex_otel && 'reflex-otel' || '' }} ${{ inputs.reflex_release && 'reflex-release' || '' }} ${{ inputs.reflex_workflow && 'reflex-workflow' || '' }} diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py index 14f407d03a4..05abfa6a601 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py @@ -127,6 +127,7 @@ def get_sidebar_items_backend(): database, enterprise, events, + i18n, state, state_structure, utility_methods, @@ -183,6 +184,12 @@ def get_sidebar_items_backend(): client_storage.overview, ], ), + create_item( + "Internationalization", + children=[ + i18n.overview, + ], + ), create_item( "Database", children=[ diff --git a/docs/i18n/overview.md b/docs/i18n/overview.md new file mode 100644 index 00000000000..3b54e067b92 --- /dev/null +++ b/docs/i18n/overview.md @@ -0,0 +1,360 @@ +```python exec +import reflex as rx +``` + +# Internationalization (i18n) + +Reflex supports translating your app into multiple languages through the +[`reflex-i18n`](https://pypi.org/project/reflex-i18n/) package. It covers the +two kinds of text in an app: + +- **Static content** — literal strings in your components (labels, buttons, + headings), translated on the client with `rx.t(...)`. +- **Dynamic content** — strings produced by your state (messages, formatted + values), translated on the server with `gettext`. + +Only the visitor's active language is ever sent to the browser, so adding more +locales does not bloat what each user downloads. + +## Installation + +```bash +pip install reflex-i18n +``` + +Then enable it by adding the `I18nPlugin` to your `rxconfig.py`, listing the +locales you support: + +```python +import reflex as rx +from reflex_i18n import I18nPlugin + +config = rx.Config( + app_name="myapp", + plugins=[ + I18nPlugin(locales=["en", "de", "fr"], default_locale="en"), + ], +) +``` + +`default_locale` is the language your source strings are written in. Translations +live in `.po` files under `locales/` (configurable with `catalog_dir=`). + +## Static content with `rx.t` + +Wrap literal component strings in `rx.t`. The text you pass is the message in +your default locale; at runtime it is looked up in the active locale's catalog, +falling back to the original text when a translation is missing. + +```python +def index(): + return rx.vstack( + rx.heading(rx.t("Welcome")), + rx.button(rx.t("Sign in")), + ) +``` + +### Interpolating values + +Use `{name}` placeholders and pass the values as keyword arguments. Values may +be plain data **or state vars** — they interpolate on the client, so they stay +reactive: + +```python +class State(rx.State): + name: str = "Ada" + + +def greeting(): + return rx.text(rx.t("Hello, {name}!", name=State.name)) +``` + +### Plurals + +Pass a `plural` form and a `count`. The correct form is chosen using the active +locale's plural rules, and `count` is also available as the `{count}` +placeholder: + +```python +class CartState(rx.State): + items: int = 1 + + +def cart_label(): + return rx.text(rx.t("{count} item", plural="{count} items", count=CartState.items)) +``` + +A message is either singular or plural: gettext keys catalog entries by their +msgid, so using the same text both with and without `plural` raises an error. +Give one of them a `context` (below) if you need both. + +### Disambiguating with context + +When the same source text needs different translations in different places, +give it a `context`: + +```python +rx.t("Open", context="verb") # "to open something" +rx.t("Open", context="status") # "currently open" +``` + +## Dynamic content with `gettext` + +For text generated in your state, import `gettext` (conventionally aliased `_`) +and call it wherever you build the string. It translates into the current +visitor's locale. + +The best place is a **computed var**: it re-runs and re-sends the translated +string automatically when the locale changes. + +```python +from reflex_i18n import gettext as _ + + +class DashboardState(rx.State): + unread: int = 0 + + @rx.var + def status(self) -> str: + return _("You have {n} new messages").format(n=self.unread) +``` + +You can also translate at the moment you produce a one-off message, such as in +an event handler: + +```python +from reflex_i18n import gettext as _, ngettext + + +class OrderState(rx.State): + message: str = "" + + @rx.event + def checkout(self): + self.message = _("Order confirmed") + + @rx.event + def summarize(self, n: int): + self.message = ngettext("{n} order", "{n} orders", n).format(n=n) +``` + +`ngettext(singular, plural, n)` handles plurals and `pgettext(context, message)` +handles context, mirroring `rx.t`. + +Note: translate at render time (in a computed var) or at the moment you emit a +message. A translated string stored in a plain var earlier will not +re-translate on its own when the locale changes. + +## Formatting numbers and dates + +Numbers, currencies, percentages and dates are formatted differently per locale +(`1,234.5` vs `1.234,5`, `7/18/2026` vs `18.07.2026`). There are two ways to +format, mirroring static vs dynamic content. + +**In components (client-side).** `rx.i18n.number` / `currency` / `percent` / +`date` / `time` / `datetime` format a value in the browser using `Intl`, and +reformat instantly when the locale changes: + +```python +import datetime + + +class PriceState(rx.State): + quantity: float = 3.5 + total: float = 41.9 + tax_rate: float = 0.19 + created: datetime.datetime = datetime.datetime.now(datetime.timezone.utc) + + +def price_row(): + return rx.hstack( + rx.text(rx.i18n.number(PriceState.quantity, max_fraction_digits=2)), + rx.text(rx.i18n.currency(PriceState.total, "EUR")), + rx.text(rx.i18n.percent(PriceState.tax_rate, max_fraction_digits=1)), + rx.text(rx.i18n.date(PriceState.created, length="long")), + ) +``` + +Curated options — `min_fraction_digits`, `max_fraction_digits`, `grouping`, +`compact`, and `length` (`"short"`/`"medium"`/`"long"`/`"full"`) — cover the +common cases; pass `options={...}` for raw +[`Intl`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) +options. + +**In state (server-side).** Use the `format_*` helpers, e.g. when building a +translated string. Like `gettext`, a computed var using one reformats when the +locale changes: + +```python +from reflex_i18n import gettext as _, format_currency + + +class CartState(rx.State): + total: float = 0.0 + + @rx.var + def summary(self) -> str: + return _("Total: {amount}").format(amount=format_currency(self.total, "EUR")) +``` + +`rx.i18n.locale` exposes the active locale as a var, e.g. to drive +`rx.moment(PriceState.created, locale=rx.i18n.locale)`. + +Two caveats: + +- **Naive datetimes client-side** are shown in the visitor's local time zone (a + datetime with no offset has no absolute meaning). For a fixed zone, use a + timezone-aware datetime, pass `options={"timeZone": "..."}`, or format + server-side with `format_datetime`. (Plain dates and times are unambiguous and + render correctly.) +- Python's `f"{value:,}"` formatting on a numeric var stays `en-US` by design; + use `rx.i18n.number` for locale-aware output. + +## Detecting and switching the locale + +On a visitor's first load, the locale is negotiated from their browser's +`Accept-Language`, falling back to `default_locale`. Once they pick a language +it is remembered in a cookie. + +Switch languages with `rx.i18n.set_locale`, which updates both static and +dynamic content: + +```python +def language_switcher(): + return rx.hstack( + rx.button("English", on_click=rx.i18n.set_locale("en")), + rx.button("Deutsch", on_click=rx.i18n.set_locale("de")), + ) +``` + +Static (`rx.t`) content updates instantly; dynamic (state) content updates on +the next server round-trip. + +`rx.i18n.language_switcher()` is a prebuilt switcher with one `` per locale. +Here it switches static and dynamic content just like `set_locale`, and becomes +a set of real crawlable links once URL routing is on (below). + +Locales are BCP 47 language tags written with hyphens (`en`, `pt-BR`, +`zh-Hant`) — they double as URL segments and `Intl` tags, so the underscore +spelling is rejected. + +## URL-based locales & SEO + +The cookie-based default keeps every language on the same URL. That's ideal for +apps (dashboards, authenticated tools), but bad for **SEO**: search engines see +only the default language, because the locale lives in a cookie/state rather +than the URL. + +For public sites, opt into **URL-based locales** by giving the plugin a routing +strategy: + +```python +from reflex_i18n import I18nPlugin, PathPrefixRouting + +config = rx.Config( + app_name="mysite", + deploy_url="https://mysite.com", # used for absolute hreflang hrefs + plugins=[ + I18nPlugin( + locales=["en", "de", "fr"], + default_locale="en", + routing=PathPrefixRouting(), # /de/..., /fr/... + ) + ], +) +``` + +You write each page **once**; the plugin fans it out into a concrete route per +locale. With the default `PathPrefixRouting(default_at_root=True)`: + +- `/pricing` serves the default locale, `/de/pricing` and `/fr/pricing` the + others (use `default_at_root=False` to prefix every locale). +- Each route is prerendered and carries reciprocal + `` (plus `x-default` and `canonical`) in + its ``, so all languages are discoverable and indexable. +- The locale comes from the **URL**, not a cookie — so each language URL renders + its own content for crawlers. + +Here the switcher's links become real per-locale URLs (crawlers follow them +instead of a cookie swap); you can also build your own with +`rx.i18n.locale_url`: + +```python +rx.i18n.language_switcher() # prebuilt links +rx.link("Deutsch", href=rx.i18n.locale_url("de", "/pricing")) +``` + +Notes: + +- Page `title`/`meta` translated with `rx.t` still resolve in the default + locale (the document head is outside the per-route locale provider); prefer + per-route titles if that matters. +- `default_at_root=True` (the default) is recommended for SEO: the default + locale keeps clean canonical URLs (`/pricing`). With `default_at_root=False` + every locale is prefixed; visiting an unprefixed path redirects to its + default-locale address (`/pricing` → `/en/pricing`), and its canonical link + points there. +- App routes must not start with a configured locale (e.g. a `/de/...` page of + your own while `de` is configured): those URLs are reserved for the generated + locale routes, and compilation fails with a clear error. +- Each locale route prerenders that language's content, `hreflang` and + `canonical`. The `` and `dir` attributes are applied on hydration: + the document root is app-wide (`rx.App(html_lang=...)`) and cannot yet vary + per route. + +## Translation catalogs + +Translations are standard gettext `.po` files, one per locale, under `locales/`: + +```text +locales/ + en.po + de.po + fr.po +``` + +A catalog entry pairs the source text (`msgid`) with its translation +(`msgstr`): + +```po +msgid "Welcome" +msgstr "Willkommen" + +msgid "{count} item" +msgid_plural "{count} items" +msgstr[0] "{count} Artikel" +msgstr[1] "{count} Artikel" +``` + +You don't write these by hand — the CLI generates and maintains them. + +## The `reflex i18n` CLI + +Once the plugin is configured, three commands manage your catalogs: + +```bash +# Scan the app for rx.t and gettext calls and update every locale's .po file +# (new messages added, translations preserved, removed ones marked obsolete). +reflex i18n extract + +# Create a fresh catalog for a new locale. +reflex i18n init es + +# Fail (non-zero exit) if any non-default locale has untranslated or fuzzy +# messages (the default locale holds the source msgids). Useful as a CI check. +reflex i18n check +``` + +A typical workflow: run `reflex i18n extract` after adding or changing strings, +fill in the `msgstr` values in each locale's `.po` file, and add +`reflex i18n check` to CI to catch missing translations. + +## How it works + +- `rx.t` compiles to a client-side lookup. At build time Reflex generates one + small JavaScript catalog per locale, and the browser loads **only the active + language** on demand. +- `gettext` runs on the server against the locale resolved for the current + client, so no translation data for other languages is sent to the browser. +- Missing translations always fall back to the source text, so an incomplete + catalog degrades gracefully rather than showing blank strings. diff --git a/examples/ruff.toml b/examples/ruff.toml index 41393b91a35..8c33feb588b 100644 --- a/examples/ruff.toml +++ b/examples/ruff.toml @@ -32,6 +32,7 @@ reflex_components_react_player.msg = "Examples import Reflex only as `import ref reflex_components_recharts.msg = "Examples import Reflex only as `import reflex as rx`." reflex_components_sonner.msg = "Examples import Reflex only as `import reflex as rx`." reflex_docgen.msg = "Examples import Reflex only as `import reflex as rx`." +reflex_i18n.msg = "Examples import Reflex only as `import reflex as rx`." reflex_otel.msg = "Examples import Reflex only as `import reflex as rx`." reflex_release.msg = "Examples import Reflex only as `import reflex as rx`." reflex_site_shared.msg = "Examples import Reflex only as `import reflex as rx`." diff --git a/news/6796.bugfix.md b/news/6796.bugfix.md new file mode 100644 index 00000000000..826d6463462 --- /dev/null +++ b/news/6796.bugfix.md @@ -0,0 +1 @@ +Hot reload no longer crashes in `_mark_dirty_computed_vars` when a computed var on a surviving state depends on a state defined in the reloaded module: dependency edges pointing at reloaded states are now purged from every remaining state instead of being left to dangle. diff --git a/news/6796.feature.md b/news/6796.feature.md new file mode 100644 index 00000000000..6d38d9a5a2f --- /dev/null +++ b/news/6796.feature.md @@ -0,0 +1 @@ +Added internationalization support through the new `reflex-i18n` package, exposed under `rx.i18n`: translate static component content with `rx.t` and dynamic state content server-side with `rx.i18n.gettext`/`ngettext`/`pgettext`, and configure locales with `rx.i18n.I18nPlugin` in `rx.Config(plugins=[...])`. With `reflex-i18n` installed, `reflex i18n` manages the translation catalogs. diff --git a/packages/reflex-base/news/6796.feature.md b/packages/reflex-base/news/6796.feature.md new file mode 100644 index 00000000000..53c5b675b98 --- /dev/null +++ b/packages/reflex-base/news/6796.feature.md @@ -0,0 +1 @@ +Added three extension points (used by `reflex-i18n`, available to any package): `register_event_scope_provider` wraps each event's handler execution and delta resolution in an ambient context that is re-derived after the handler runs (e.g. the active locale), which plain middleware cannot do; `register_implicit_dependency` marks functions whose use inside a computed var getter implies a dependency on a var the getter never reads directly (e.g. a gettext helper reading the active locale from a contextvar); and a plugin's `expand_routes` hook (`reflex_base.plugins.ExpandRoutesContext`) can register extra pages derived from the app's own, e.g. one route per locale. The first two are no-ops with negligible cost when nothing is registered. diff --git a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py index 7d13b83e437..012b50af185 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py @@ -22,6 +22,7 @@ from reflex_base.event.context import EventContext from reflex_base.event.processor.event_processor import EventProcessor, EventQueueEntry from reflex_base.event.processor.future import EventFuture +from reflex_base.event.processor.scope import event_scope from reflex_base.registry import RegisteredEventHandler from reflex_base.utils.form import form_data_as_dict, transform_form_data from reflex_base.utils.format import format_event_handler @@ -260,13 +261,17 @@ async def chain_updates( if root_state is not None: # Emit deltas first, so any frontend events are processed with the - # latest state. The clean deliberately runs after resolution: the + # latest state. Enter the event scope again here (after the handler + # ran): a provider may derive its context from state the handler just + # changed (e.g. the locale), and computed vars are recomputed during + # delta resolution. The clean deliberately runs after resolution: the # SharedState fan-out captures its dirty vars at clean time, and # resolving the delta is what re-marks linked vars through the patch # machinery, so cleaning earlier would fan out a stale set (see # tests/integration/test_linked_state.py). try: - delta = await root_state._get_resolved_delta() + async with event_scope(root_state): + delta = await root_state._get_resolved_delta() if delta: await ctx.emit_delta(delta) finally: @@ -592,18 +597,22 @@ async def _execute_event( substate = await state.get_state(registered_handler.states[0]) root_state = state._get_root_state() - if needs_to_rehydrate: - await self._rehydrate(root_state) - - # Process non-background events while holding the lock. - if not registered_handler.handler.is_background: - await process_event( - handler=registered_handler.handler, - payload=event.payload, - state=substate, - root_state=root_state, - ) - return + # Enter any per-event ambient context (e.g. i18n locale) around + # handler execution. A no-op unless a feature registered a scope + # provider, so apps without one pay effectively nothing. + async with event_scope(root_state): + if needs_to_rehydrate: + await self._rehydrate(root_state) + + # Process non-background events while holding the lock. + if not registered_handler.handler.is_background: + await process_event( + handler=registered_handler.handler, + payload=event.payload, + state=substate, + root_state=root_state, + ) + return # Otherwise drop the state lock and start processing the background task # with a proxy state. No root_state: the lock is no longer held, and # under a shared state tree (opportunistic locking, in-memory manager) @@ -613,18 +622,21 @@ async def _execute_event( # background task's own state changes are emitted (and cleaned) by its # `async with self` context exits, which re-acquire the lock. proxy = StateProxy(substate) + # The scope releases the root state once entered, so it pins no tree. + scope = event_scope(root_state) # Leave the tree loaded under the lock to the proxy, which replaces it on # `async with self`. A manager like redis loads a fresh tree for every # lock, so these locals would pin an extra copy for the whole task. del state, substate, root_state handler_error: BaseException | None = None try: - await process_event( - handler=registered_handler.handler, - state=proxy, - payload=event.payload, - root_state=None, - ) + async with scope: + await process_event( + handler=registered_handler.handler, + state=proxy, + payload=event.payload, + root_state=None, + ) except BaseException as ex: handler_error = ex raise diff --git a/packages/reflex-base/src/reflex_base/event/processor/scope.py b/packages/reflex-base/src/reflex_base/event/processor/scope.py new file mode 100644 index 00000000000..ced810772d0 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/event/processor/scope.py @@ -0,0 +1,95 @@ +"""Per-event ambient context providers (e.g. i18n locale). + +A provider yields a context manager entered around both handler execution and +delta resolution; it is called fresh at each phase, so state a handler changed +is re-read. Middleware can't do this (separate pre/post calls). No registered +providers means a ``nullcontext`` fast path. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from types import TracebackType + + from reflex.state import BaseState + +# A provider takes the root state and returns (async) a synchronous context +# manager to enter for the current event phase. +EventScopeProvider = Callable[ + ["BaseState"], Awaitable[contextlib.AbstractContextManager[None]] +] + +_providers: list[EventScopeProvider] = [] + + +def register_event_scope_provider(provider: EventScopeProvider) -> None: + """Register a per-event ambient-context provider. + + Args: + provider: Async callable from root state to a context manager. + """ + _providers.append(provider) + + +def has_event_scope_providers() -> bool: + """Whether any event-scope providers are registered. + + Returns: + True if at least one provider is registered. + """ + return bool(_providers) + + +class _EventScope: + """Async context manager entering every registered provider's scope.""" + + __slots__ = ("_root_state", "_stack") + + def __init__(self, root_state: BaseState): + self._root_state = root_state + self._stack = contextlib.ExitStack() + + async def __aenter__(self) -> None: + # Drop the reference so a background task's scope does not pin the tree + # its proxy replaces on `async with self`. + root_state = self._root_state + del self._root_state + # ``async with`` only calls ``__aexit__`` if ``__aenter__`` returns, so + # a provider raising mid-loop would otherwise leak the contexts already + # entered (e.g. an earlier provider's contextvar token). + try: + for provider in _providers: + self._stack.enter_context(await provider(root_state)) + except BaseException: + self._stack.close() + raise + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + # Forwarded so a provider's context manager can observe (and suppress) + # an exception raised by the handler or by delta resolution. + return self._stack.__exit__(exc_type, exc_value, traceback) + + +def event_scope( + root_state: BaseState, +) -> contextlib.AbstractAsyncContextManager[None]: + """Ambient-context scope for processing an event. + + Args: + root_state: The client's root state instance. + + Returns: + A scope entering every provider, or a no-op when none are registered. + """ + if not _providers: + return contextlib.nullcontext() + return _EventScope(root_state) diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py index c82a36d559f..cdde8beea42 100644 --- a/packages/reflex-base/src/reflex_base/plugins/__init__.py +++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py @@ -18,6 +18,7 @@ from ._screenshot import ScreenshotPlugin as _ScreenshotPlugin from .base import ( CommonContext, + ExpandRoutesContext, Plugin, PostBuildContext, PostCompileContext, @@ -45,6 +46,7 @@ "CompilerHooks", "ComponentAndChildren", "EmbedPlugin", + "ExpandRoutesContext", "PageContext", "PageDefinition", "Plugin", @@ -78,6 +80,7 @@ "_screenshot": [("ScreenshotPlugin", "_ScreenshotPlugin")], "base": [ "CommonContext", + "ExpandRoutesContext", "Plugin", "PostBuildContext", "PostCompileContext", diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index 3f52afe48ee..dbb542e3f83 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -106,6 +106,12 @@ class RegisterRouteContext(CommonContext): has_app_page: Callable[[str], bool] +class ExpandRoutesContext(RegisterRouteContext): + """Context for ``expand_routes``: adds the app's registered ``pages``.""" + + pages: Sequence["UnevaluatedPage"] + + class PostCompileContext(CommonContext): """Context for post-compile hooks.""" @@ -194,6 +200,16 @@ def register_route(self, **context: Unpack[RegisterRouteContext]) -> None: context: The route registration context. """ + def expand_routes(self, **context: Unpack[ExpandRoutesContext]) -> None: + """Contribute pages derived from the app's already-registered pages. + + Runs after ``register_route`` with the same staged ``add_page``; the + context adds ``pages`` (the app's registered pages) to fan out from. + + Args: + context: The route-expansion context. + """ + def pre_compile(self, **context: Unpack[PreCompileContext]) -> None: """Called before the compilation of the plugin. diff --git a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py index 733b5274820..ac24220de70 100644 --- a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py +++ b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py @@ -9,6 +9,7 @@ import importlib import inspect import sys +from collections.abc import Callable, Iterable from types import CellType, CodeType, FunctionType, MemberDescriptorType, ModuleType from typing import TYPE_CHECKING, Any, ClassVar, cast @@ -22,6 +23,26 @@ CellEmpty = object() +# Functions whose mere use inside a computed var getter implies a dependency on +# a var, even though the getter never reads that var directly (e.g. a gettext +# helper that reads the active locale from a contextvar). Maps the function to a +# provider returning the implied dependency Var, or None when inactive. +_implicit_dependency_providers: dict[object, Callable[[], Var | None]] = {} + + +def register_implicit_dependency( + funcs: Iterable[object], provider: Callable[[], Var | None] +) -> None: + """Register functions that imply a computed-var dependency when referenced. + + Args: + funcs: The functions to detect (matched by object identity). + provider: Returns the implied dependency Var, or None when the + dependency is not currently applicable. + """ + for func in funcs: + _implicit_dependency_providers[func] = provider + def get_cell_value(cell: CellType) -> Any: """Get the value of a cell object. @@ -118,6 +139,14 @@ class DependencyTracker: _get_var_value_positions: dis.Positions | None = dataclasses.field(default=None) _last_import_name: str | None = dataclasses.field(default=None) + # A module loaded by the previous instruction, so `pkg.mod.helper` can be + # resolved attribute by attribute (a code object has no globals of its own). + _attr_base: ModuleType | None = dataclasses.field(default=None) + # Globals and closure of the enclosing function when ``func`` is a nested + # code object (comprehension, lambda), which carries neither itself. + _globals: dict[str, Any] | None = dataclasses.field(default=None) + _closure: dict[str, Any] | None = dataclasses.field(default=None) + INVALID_NAMES: ClassVar[list[str]] = ["parent_state", "substates", "get_substate"] def __post_init__(self): @@ -198,6 +227,10 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None: if not self.top_of_stack: return target_obj = self.get_tracked_local(self.top_of_stack) + if isinstance(target_obj, ModuleType): + # `mod.helper` on an inline-imported module. + self._load_module_attr(target_obj, instruction.argval) + return try: target_state = assert_base_state(target_obj, local_name=self.top_of_stack) except VarValueError: @@ -230,6 +263,39 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None: instruction.argval ) + def _load_module_attr(self, module: ModuleType, name: str) -> None: + """Resolve an attribute of a module; detect helpers, follow submodules. + + Args: + module: The module on top of the stack. + name: The attribute being loaded. + """ + obj = getattr(module, name, None) + self._add_implicit_dependency(obj) + self._attr_base = obj if isinstance(obj, ModuleType) else None + + def _add_implicit_dependency(self, obj: object) -> None: + """Record an implied dependency if ``obj`` is a registered function. + + Args: + obj: The object a load instruction resolved to (may be anything). + """ + if not _implicit_dependency_providers: + return + try: + provider = _implicit_dependency_providers.get(obj) + except TypeError: + return # unhashable objects can't be registered functions + if provider is None: + return + dep_var = provider() + if dep_var is None: + return + var_data = dep_var._get_all_var_data() + if var_data is None or not var_data.state: + return + self.dependencies.setdefault(var_data.state, set()).add(var_data.field_name) + def _get_globals(self) -> dict[str, Any]: """Get the globals of the function. @@ -237,7 +303,7 @@ def _get_globals(self) -> dict[str, Any]: The var names and values in the globals of the function. """ if isinstance(self.func, CodeType): - return {} + return self._globals or {} return self.func.__globals__ # pyright: ignore[reportAttributeAccessIssue] def _get_closure(self) -> dict[str, Any]: @@ -247,7 +313,7 @@ def _get_closure(self) -> dict[str, Any]: The var names and values in the closure of the function. """ if isinstance(self.func, CodeType): - return {} + return self._closure or {} return { var_name: get_cell_value(cell) for var_name, cell in zip( @@ -415,6 +481,8 @@ def _populate_dependencies(self) -> None: define comprehensions or nested functions that may reference "self". """ for instruction in dis.get_instructions(self.func): + # Only the instruction right after a module load continues its chain. + attr_base, self._attr_base = self._attr_base, None if self.scan_status == ScanStatus.GETTING_STATE: self.handle_getting_state(instruction) elif self.scan_status == ScanStatus.GETTING_STATE_POST_AWAIT: @@ -432,29 +500,43 @@ def _populate_dependencies(self) -> None: ) and instruction.argval in self.tracked_locals ): + # A tracked local may be an inline-imported function that + # implies a dependency (e.g. a gettext helper). + self._add_implicit_dependency(self.tracked_locals[instruction.argval]) # bytecode loaded the class instance to the top of stack, next load instruction # is referencing an attribute on self self.top_of_stack = instruction.argval self.scan_status = ScanStatus.GETTING_ATTR - elif ( - instruction.opname - in ( - "LOAD_FAST_LOAD_FAST", - "LOAD_FAST_BORROW_LOAD_FAST_BORROW", - "STORE_FAST_LOAD_FAST", - ) - and instruction.argval[-1] in self.tracked_locals + elif instruction.opname in ( + "LOAD_FAST_LOAD_FAST", + "LOAD_FAST_BORROW_LOAD_FAST_BORROW", + "STORE_FAST_LOAD_FAST", ): - # Double LOAD_FAST family instructions load multiple values onto the stack, - # the last value in the argval list is the top of the stack. - self.top_of_stack = instruction.argval[-1] - self.scan_status = ScanStatus.GETTING_ATTR + # Every loaded name (the first one is a store, not a load, in + # STORE_FAST_LOAD_FAST) may be an inline-imported function + # that implies a dependency, same as the single-load case. + loaded_from = 1 if instruction.opname == "STORE_FAST_LOAD_FAST" else 0 + for name in instruction.argval[loaded_from:]: + if name in self.tracked_locals: + self._add_implicit_dependency(self.tracked_locals[name]) + if instruction.argval[-1] in self.tracked_locals: + # Double LOAD_FAST family instructions load multiple values onto the stack, + # the last value in the argval list is the top of the stack. + self.top_of_stack = instruction.argval[-1] + self.scan_status = ScanStatus.GETTING_ATTR elif self.scan_status == ScanStatus.GETTING_ATTR and instruction.opname in ( "LOAD_ATTR", "LOAD_METHOD", ): self.load_attr_or_method(instruction) self.top_of_stack = None + elif attr_base is not None and instruction.opname in ( + "LOAD_ATTR", + "LOAD_METHOD", + ): + # `pkg.mod.helper(...)`: walk the attribute chain off a module + # so a module-qualified helper reference is detected too. + self._load_module_attr(attr_base, instruction.argval) elif instruction.opname == "LOAD_CONST" and isinstance( instruction.argval, CodeType ): @@ -465,8 +547,28 @@ def _populate_dependencies(self) -> None: func=instruction.argval, state_cls=self.state_cls, tracked_locals=self.tracked_locals, + _globals=self._get_globals(), + _closure=self._get_closure(), ) ) + elif ( + instruction.opname == "LOAD_GLOBAL" + and self.scan_status == ScanStatus.SCANNING + ): + # A referenced global may be a function that implies a + # dependency (e.g. a gettext helper reading the active locale), + # or a module such a helper is then loaded from. + obj = self._get_globals().get(instruction.argval) + self._add_implicit_dependency(obj) + self._attr_base = obj if isinstance(obj, ModuleType) else None + elif ( + instruction.opname == "LOAD_DEREF" + and self.scan_status == ScanStatus.SCANNING + ): + # Same as above for a closure-captured reference. + obj = self._get_closure().get(instruction.argval) + self._add_implicit_dependency(obj) + self._attr_base = obj if isinstance(obj, ModuleType) else None elif instruction.opname == "IMPORT_NAME": if instruction.argval is None: continue diff --git a/packages/reflex-i18n/README.md b/packages/reflex-i18n/README.md new file mode 100644 index 00000000000..e48a4fbf561 --- /dev/null +++ b/packages/reflex-i18n/README.md @@ -0,0 +1,39 @@ +# reflex-i18n + +Internationalization (i18n) for [Reflex](https://reflex.dev) apps. + +- `rx.t(...)` — translate static component strings, resolved client-side from + per-locale catalogs (only the active locale is shipped to the client). +- `gettext` / `ngettext` / `pgettext` — translate dynamic (state) content + server-side; translated computed vars retranslate automatically on a locale + switch. +- `I18nPlugin` — configure locales and wire compilation. + +```python +import reflex as rx +from reflex_i18n import I18nPlugin, t, gettext as _ + + +class State(rx.State): + @rx.var + def greeting(self) -> str: + return _("Hello") + + +def index(): + return rx.text(t("Welcome")) + + +app = rx.App() +app.add_page(index) +``` + +```python +# rxconfig.py +config = rx.Config( + app_name="myapp", + plugins=[I18nPlugin(locales=["en", "de"], default_locale="en")], +) +``` + +Translations live in `locales/{locale}.po`. diff --git a/packages/reflex-i18n/news/6796.feature.md b/packages/reflex-i18n/news/6796.feature.md new file mode 100644 index 00000000000..d64d82d3adc --- /dev/null +++ b/packages/reflex-i18n/news/6796.feature.md @@ -0,0 +1 @@ +New `reflex-i18n` package providing internationalization for Reflex apps: `rx.t` for translating static component text, server-side `gettext`/`ngettext`/`pgettext` for dynamic state content, an `I18nPlugin`/`I18nConfig` to configure available locales, an `I18nState` whose active locale is backed by a cookie, and a `reflex i18n` CLI for extracting message catalogs and compiling `.po`/`.mo` files via Babel. diff --git a/packages/reflex-i18n/pyproject.toml b/packages/reflex-i18n/pyproject.toml new file mode 100644 index 00000000000..2b9a2c7959d --- /dev/null +++ b/packages/reflex-i18n/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "reflex-i18n" +dynamic = ["version"] +description = "Internationalization (i18n) for Reflex apps." +license.text = "Apache-2.0" +readme = "README.md" +authors = [{ name = "Reflex", email = "maintainers@reflex.dev" }] +maintainers = [{ name = "Reflex", email = "maintainers@reflex.dev" }] +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", +] +dependencies = [ + # TODO: Bump to the released versions before publishing. + "reflex-base >= 0.10.1.dev0", + "reflex >= 0.10.1.dev0", + "babel >= 2.14.0,<3.0", +] + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +pattern-prefix = "reflex-i18n-" +fallback-version = "0.0.0dev0" +bump = true + +[tool.hatch.build] +artifacts = ["_web/**"] + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" diff --git a/packages/reflex-i18n/src/reflex_i18n/__init__.py b/packages/reflex-i18n/src/reflex_i18n/__init__.py new file mode 100644 index 00000000000..bc571644a4c --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/__init__.py @@ -0,0 +1,107 @@ +"""Internationalization (i18n) for Reflex apps. + +Static (component) content is translated with :func:`t`; dynamic (state) +content is translated server-side with :func:`gettext` / :func:`ngettext` / +:func:`pgettext`. Configure locales with :class:`I18nPlugin` in +``rx.Config(plugins=[...])``. +""" + +from typing import TYPE_CHECKING, Any + +from .config import LOCALE_COOKIE_NAME, I18nConfig + +# date/datetime/time use the alias form and are omitted from __all__ so +# `from reflex_i18n import *` cannot shadow the stdlib names; they remain +# available as rx.i18n.date / .time / .datetime (prefer attribute access). +from .format import currency, number, percent +from .format import date as date +from .format import datetime as datetime +from .format import time as time +from .nav import language_switcher, locale_url +from .plugin import I18nPlugin +from .routing import PathPrefixRouting +from .runtime import ( + format_currency, + format_date, + format_datetime, + format_decimal, + format_number, + format_percent, + format_time, + gettext, + ngettext, + pgettext, +) +from .vars import t + +if TYPE_CHECKING: + from reflex_base.vars.sequence import StringVar + + from .state import I18nState, set_locale + + # The active locale as a client-side var; resolved lazily (see below). + locale: StringVar + +# gettext alias, the conventional shorthand for marking translatable strings. +_ = gettext + +# "date", "datetime" and "time" are intentionally omitted (see the import +# above): reachable as rx.i18n.date/.time/.datetime but excluded from +# `import *` so they cannot shadow the stdlib names. "locale" is omitted for a +# different reason: `import *` resolves every exported name, and that one only +# builds once an I18nPlugin is configured. +__all__ = [ + "LOCALE_COOKIE_NAME", + "I18nConfig", + "I18nPlugin", + "I18nState", + "PathPrefixRouting", + "currency", + "format_currency", + "format_date", + "format_datetime", + "format_decimal", + "format_number", + "format_percent", + "format_time", + "gettext", + "language_switcher", + "locale_url", + "ngettext", + "number", + "percent", + "pgettext", + "set_locale", + "t", +] + +# Importing ``.state`` registers ``I18nState`` as a substate (a global side +# effect). Defer it so merely importing this package (e.g. the reflex CLI +# loading the ``reflex i18n`` command) does not attach i18n state to apps +# that never use i18n; opting in via ``I18nPlugin`` or accessing these names +# does. +_LAZY_STATE_ATTRS = frozenset({"I18nState", "set_locale"}) + + +def __getattr__(name: str) -> Any: + """Lazily resolve state-registering attributes. + + Args: + name: The attribute name. + + Returns: + The resolved attribute. + + Raises: + AttributeError: If the attribute is not part of the public API. + """ + if name in _LAZY_STATE_ATTRS: + from . import state + + return getattr(state, name) + if name == "locale": + from .format import _locale_var + + return _locale_var() + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js b/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js new file mode 100644 index 00000000000..78a5f697132 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js @@ -0,0 +1,478 @@ +import { + createContext, + createElement, + Fragment, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useHref, useLocation, useNavigate } from "react-router"; + +import { + cookieName, + defaultAtRoot, + defaultLocale, + defaultPlural, + deployUrl, + loaders, + locales, + urlRouting, +} from "$/i18n/index.js"; + +// gettext msgctxt separator; catalog keys are "context\u0004msgid". +const CONTEXT_SEPARATOR = "\u0004"; + +// Language subtags written right-to-left. +const RTL_LANGUAGES = new Set([ + "ar", + "arc", + "ckb", + "dv", + "fa", + "he", + "ks", + "ps", + "sd", + "ug", + "ur", + "yi", +]); + +const stripContext = (key) => { + const index = key.indexOf(CONTEXT_SEPARATOR); + return index === -1 ? key : key.slice(index + 1); +}; + +const interpolate = (message, params) => + message.replace(/\{(\w+)\}/g, (match, name) => + params && name in params ? String(params[name]) : match, + ); + +const readCookie = (name) => { + const match = document.cookie + .split("; ") + .find((row) => row.startsWith(name + "=")); + return match ? decodeURIComponent(match.split("=")[1]) : undefined; +}; + +const writeCookie = (name, value) => { + const secure = location.protocol === "https:" ? "; secure" : ""; + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=31536000; samesite=lax${secure}`; +}; + +// Match a requested locale list against the supported locales: exact tag +// first, then primary-language prefix (e.g. "de-AT" -> "de"). Like the +// server's negotiate_locale, tags match case-insensitively (RFC 5646) and +// resolve to the configured spelling. +const negotiate = (requested) => { + const byTag = new Map(); + const byLanguage = new Map(); + for (const supported of locales) { + const tag = supported.toLowerCase(); + byTag.set(tag, supported); + const language = tag.split("-")[0]; + if (!byLanguage.has(language)) { + byLanguage.set(language, supported); + } + } + for (const tag of requested) { + const lower = tag.toLowerCase(); + const match = byTag.get(lower) ?? byLanguage.get(lower.split("-")[0]); + if (match !== undefined) { + return match; + } + } + return undefined; +}; + +const initialLocale = () => { + const fromCookie = readCookie(cookieName); + if (fromCookie && locales.includes(fromCookie)) { + return fromCookie; + } + return ( + negotiate(navigator.languages ?? [navigator.language]) ?? defaultLocale + ); +}; + +export const I18nContext = createContext({ + locale: defaultLocale, + catalog: undefined, + setLocale: () => {}, +}); + +// The mounted provider's setter, so a Reflex event (run_script) can switch +// the locale without threading context through the calling component. +let _switchLocale = null; + +export function switchLocale(locale, options) { + if (_switchLocale) { + _switchLocale(locale, options); + } +} + +// Publish a setter as the one `switchLocale` (a Reflex event) drives, without +// clobbering another provider's on unmount. +const usePublishedSetter = (setLocale) => { + useEffect(() => { + _switchLocale = setLocale; + return () => { + if (_switchLocale === setLocale) { + _switchLocale = null; + } + }; + }, [setLocale]); +}; + +// Switch locale by navigating to the current page's address in the target +// locale, keeping the query string and fragment. +const useLocaleNavigate = () => { + const { pathname, search, hash } = useLocation(); + const navigate = useNavigate(); + return useCallback( + (nextLocale, options) => + navigate( + localizePath(delocalizePath(pathname), nextLocale) + search + hash, + options, + ), + [pathname, search, hash, navigate], + ); +}; + +export function I18nProvider({ children, onLocaleChange }) { + const { pathname } = useLocation(); + const navigateLocale = useLocaleNavigate(); + // The compiled event binding (cookie mode only) may change identity between + // renders; reading it through a ref keeps setLocale stable. + const onLocaleChangeRef = useRef(onLocaleChange); + onLocaleChangeRef.current = onLocaleChange; + // Cookie mode starts from the default locale so the server/first render is + // deterministic and never touches document/navigator; the cookie- and + // browser-based locale is resolved client-side in the effect below. + const [cookieLocale, setCookieLocale] = useState(defaultLocale); + // With URL routing the path is authoritative (mirroring the server), so + // the cookie and browser preferences never override what the URL names. + const locale = urlRouting + ? (pathLocale(pathname) ?? defaultLocale) + : cookieLocale; + const [catalog, setCatalog] = useState(undefined); + + useEffect(() => { + if (!urlRouting) { + setCookieLocale(initialLocale()); + } + }, []); + + useEffect(() => { + let cancelled = false; + loaders[locale]() + .then((module_) => { + if (!cancelled) { + setCatalog(module_); + } + }) + .catch((error) => { + // A failed chunk load (e.g. a stale hashed chunk after a redeploy) + // falls back to the source msgids rather than keeping the previous + // locale's catalog. Surface it instead of an unhandled rejection. + if (!cancelled) { + setCatalog(undefined); + } + console.error( + `Failed to load i18n catalog for locale "${locale}".`, + error, + ); + }); + // In URL mode this locale equals the route's, so setting /dir + // here agrees with LocaleRoute and also covers unwrapped (default-at-root) + // pages. + const root = document.documentElement; + root.lang = locale; + root.dir = RTL_LANGUAGES.has(locale.split("-")[0]) ? "rtl" : "ltr"; + return () => { + cancelled = true; + }; + }, [locale]); + + const setLocale = useCallback( + (nextLocale, { notifyServer = true } = {}) => { + if (!locales.includes(nextLocale)) { + console.error( + `Invalid locale "${nextLocale}". Supported locales: ${locales.join(", ")}.`, + ); + return; + } + if (urlRouting) { + // The URL owns the locale in this mode: switch by navigating to the + // localized address (the cookie is not consulted). + navigateLocale(nextLocale); + return; + } + // The cookie is the source of truth for a chosen locale; only an + // explicit choice writes it, so browser-preference changes keep + // applying for users who never picked a language. + writeCookie(cookieName, nextLocale); + setCookieLocale(nextLocale); + // Keep the server's locale (server-side translations) in step. A caller + // that already sends I18nState.set_locale itself opts out. + if (notifyServer) { + onLocaleChangeRef.current?.(nextLocale); + } + }, + [navigateLocale], + ); + + usePublishedSetter(setLocale); + + return createElement( + I18nContext.Provider, + { value: { locale, catalog, setLocale } }, + children, + ); +} + +// Cached Intl formatter instances, keyed by locale + serialized options, so a +// list of many formatted values reuses one formatter per (locale, options). +const _numberFormatters = new Map(); +const _dateFormatters = new Map(); + +// In normal use the key set is small (locales x compile-time option sets), but +// runtime-varying options (a Var in `options=`) could grow it, so cap the cache +// and evict the oldest entry (Map preserves insertion order). +const FORMATTER_CACHE_LIMIT = 100; + +const getFormatter = (cache, Ctor, locale, options) => { + const key = locale + " " + JSON.stringify(options ?? {}); + let formatter = cache.get(key); + if (formatter === undefined) { + formatter = new Ctor(locale, options); + if (cache.size >= FORMATTER_CACHE_LIMIT) { + cache.delete(cache.keys().next().value); + } + cache.set(key, formatter); + } + return formatter; +}; + +// Turn a value into a Date, normalizing Python's str(date/datetime/time): +// trim microseconds to milliseconds; parse a date-only value as local midnight +// (not UTC, which would shift the day in negative offsets); anchor a bare time +// to the epoch date so Intl can format it (new Date("14:30:00") is invalid). +const toDate = (value) => { + if (value instanceof Date) return value; + if (typeof value === "number") return new Date(value); + const s = String(value).replace(/(\.\d{3})\d+/, "$1"); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return new Date(`${s}T00:00:00`); + if (/^\d{2}:\d{2}/.test(s)) return new Date(`1970-01-01T${s}`); + return new Date(s.replace(" ", "T")); +}; + +export function useFormat() { + const { locale } = useContext(I18nContext); + const formatNumber = useCallback( + (value, options) => + getFormatter( + _numberFormatters, + Intl.NumberFormat, + locale, + options, + ).format(value), + [locale], + ); + const formatDate = useCallback( + (value, options) => + getFormatter( + _dateFormatters, + Intl.DateTimeFormat, + locale, + options, + ).format(toDate(value)), + [locale], + ); + return [formatNumber, formatDate]; +} + +export function useLocale() { + return useContext(I18nContext).locale; +} + +export function useTranslation() { + const { catalog } = useContext(I18nContext); + + const t_ = useCallback( + (key, params) => { + const message = catalog?.messages.get(key) ?? stripContext(key); + return interpolate(message, params); + }, + [catalog], + ); + + const tp_ = useCallback( + (key, pluralMessage, count, params) => { + const entry = catalog?.messages.get(key); + // Uncataloged messages fall back to the two source strings, picked by + // the DEFAULT locale's plural rule (they are written in that language). + const message = Array.isArray(entry) + ? (entry[catalog.plural(count)] ?? entry[entry.length - 1]) + : defaultPlural(count) === 0 + ? stripContext(key) + : pluralMessage; + return interpolate(message, params); + }, + [catalog], + ); + + return [t_, tp_]; +} + +// --- URL-based locale routing (opt-in via I18nPlugin(routing=...)) --- + +// Provide a fixed locale + statically-imported catalog to a route's subtree. +// The catalog is a static import (bundled with the route chunk), so the right +// language is present synchronously during prerender. +export function LocaleRoute({ locale, catalog, children }) { + const navigateLocale = useLocaleNavigate(); + // Its own navigate-backed setter rather than the I18nProvider's: a page that + // uses neither rx.t nor a formatting var never pulls the provider in. + const setLocale = useCallback( + (nextLocale) => { + if (!locales.includes(nextLocale)) { + console.error( + `Invalid locale "${nextLocale}". Supported locales: ${locales.join(", ")}.`, + ); + return; + } + navigateLocale(nextLocale); + }, + [navigateLocale], + ); + usePublishedSetter(setLocale); + useEffect(() => { + const root = document.documentElement; + root.lang = locale; + root.dir = RTL_LANGUAGES.has(locale.split("-")[0]) ? "rtl" : "ltr"; + }, [locale]); + return createElement( + I18nContext.Provider, + { value: { locale, catalog, setLocale } }, + children, + ); +} + +// Path-prefix helpers (mirror reflex_i18n.routing.PathPrefixRouting). +const pathLocale = (pathname) => { + const head = pathname.replace(/^\//, "").split("/")[0]; + return locales.includes(head) ? head : undefined; +}; + +const delocalizePath = (pathname) => { + const [head, ...rest] = pathname.replace(/^\//, "").split("/"); + if (locales.includes(head)) { + return "/" + rest.join("/"); + } + return pathname.startsWith("/") ? pathname : "/" + pathname; +}; + +const localizePath = (base, locale) => { + if (locale === defaultLocale && defaultAtRoot) { + return base; + } + return base === "/" ? "/" + locale : "/" + locale + base; +}; + +const absoluteUrl = (path) => { + const base = (deployUrl || "").replace(/\/$/, ""); + return base ? base + path : path; +}; + +// React Router paths (useLocation, navigate) are relative to the router +// basename (Reflex's `frontend_path`); raw / hrefs are not, so they +// need the basename prepended. useHref("/") is the basename with a trailing "/". +const useBasename = () => useHref("/").replace(/\/$/, ""); + +// Emit reciprocal + canonical for the current +// route. Rendered as an app-wrap so it applies to every page; React hoists the +// links into and they prerender into the static HTML. +export function HreflangLinks({ children }) { + const { pathname } = useLocation(); + const basename = useBasename(); + const navigateLocale = useLocaleNavigate(); + // When every locale is prefixed (defaultAtRoot off), an unprefixed URL has + // no locale of its own; send it to its default-locale address. This wrap is + // on every page with URL routing, unlike the provider. + useEffect(() => { + if (!defaultAtRoot && pathLocale(pathname) === undefined) { + navigateLocale(defaultLocale, { replace: true }); + } + }, [pathname, navigateLocale]); + const base = delocalizePath(pathname); + const href = (locale) => absoluteUrl(basename + localizePath(base, locale)); + const links = locales.map((locale) => + createElement("link", { + key: locale, + rel: "alternate", + hrefLang: locale, + href: href(locale), + }), + ); + links.push( + createElement("link", { + key: "x-default", + rel: "alternate", + hrefLang: "x-default", + href: href(defaultLocale), + }), + createElement("link", { + key: "canonical", + rel: "canonical", + // The localized URL for the page's own locale: identical to pathname + // except for an unprefixed page when every locale is prefixed, whose + // canonical is its default-locale address. + href: href(pathLocale(pathname) ?? defaultLocale), + }), + ); + // This is an app-wrap: render the links AND pass the app content through. + return createElement(Fragment, null, ...links, children); +} + +// A language switcher: one per locale. With URL routing these are real +// links to the current page in each locale, so crawlers follow them and the +// URL stays the source of truth; without it there is only one URL per page, so +// they set the locale through the provider (and cookie) instead. +export function LanguageSwitcher(props) { + const { pathname, search, hash } = useLocation(); + const basename = useBasename(); + const { locale: active, setLocale } = useContext(I18nContext); + const base = delocalizePath(pathname); + // Keep the query string and fragment: switching language must not drop the + // filters or the anchor the visitor is on. The clean canonical URL for each + // locale is emitted by HreflangLinks, so crawlers still see one per page. + const rest = search + hash; + return createElement( + "nav", + props, + ...locales.map((locale) => + createElement( + "a", + { + key: locale, + href: + basename + + (urlRouting ? localizePath(base, locale) : pathname) + + rest, + hrefLang: locale, + "aria-current": locale === active ? "true" : undefined, + onClick: urlRouting + ? undefined + : (event) => { + event.preventDefault(); + setLocale(locale); + }, + }, + locale, + ), + ), + ); +} diff --git a/packages/reflex-i18n/src/reflex_i18n/catalog.py b/packages/reflex-i18n/src/reflex_i18n/catalog.py new file mode 100644 index 00000000000..0ea6db53642 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/catalog.py @@ -0,0 +1,208 @@ +"""Compile ``.po`` catalogs into the per-locale JS modules served to clients.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING + +from reflex_base.utils import console + +from .config import LOCALE_COOKIE_NAME, I18nConfig +from .registry import MessageKey + +if TYPE_CHECKING: + from babel.messages.catalog import Catalog + +# C plural expressions from the Plural-Forms header are (almost) valid JS; +# whitelist their tokens before embedding one in generated code. +_PLURAL_EXPR_ALLOWED = re.compile(r"^[ n0-9()%?:!<>=&|+*/-]+$") +_FALLBACK_PLURAL_EXPR = "n != 1" + +_GENERATED_HEADER = "// Generated by Reflex. Do not edit.\n" + + +def read_po_catalog(path: Path, locale: str | None = None) -> Catalog: + """Parse a ``.po`` catalog file. + + Args: + path: The path of the ``.po`` file. + locale: The catalog's locale, supplying CLDR plural rules for a file + whose header declares none. + + Returns: + The parsed catalog. + """ + from babel.messages.pofile import read_po + + with path.open("r", encoding="utf-8") as po_file: + try: + return read_po(po_file, locale=locale and locale.replace("-", "_")) + except ValueError: + # A tag Babel has no CLDR data for; the catalog's own header (or + # the generic rule) decides the plural forms. read_po builds the + # Catalog before reading, so nothing was consumed. + return read_po(po_file) + + +def _plural_expr_js(catalog: Catalog | None, locale: str) -> str: + """Get the catalog's plural expression as a safe JS expression. + + Args: + catalog: The parsed catalog, if one exists for the locale. + locale: The locale being compiled, for warnings. + + Returns: + The validated plural expression over the variable ``n``. + """ + if catalog is None: + return _FALLBACK_PLURAL_EXPR + expr = catalog.plural_expr + if not _PLURAL_EXPR_ALLOWED.match(expr): + console.warn( + f"Ignoring invalid Plural-Forms expression {expr!r} for locale " + f"{locale!r}; falling back to {_FALLBACK_PLURAL_EXPR!r}." + ) + return _FALLBACK_PLURAL_EXPR + return expr + + +def compile_catalog_module( + catalog: Catalog | None, + used_messages: Sequence[MessageKey], + locale: str, + *, + is_default_locale: bool, +) -> str: + """Render the JS catalog module for one locale (used messages only). + + Args: + catalog: The parsed ``.po`` catalog, or None if the locale has none. + used_messages: All messages collected from ``rx.t`` calls. + locale: The locale being compiled, for warnings. + is_default_locale: If True, missing translations are not warned about. + + Returns: + The JS module source code. + """ + entries: list[str] = [] + missing: list[MessageKey] = [] + for key in used_messages: + translation = _lookup_translation(catalog, key) + if translation is None: + missing.append(key) + continue + entries.append(f" [{json.dumps(key.catalog_key)}, {json.dumps(translation)}],") + if missing and not is_default_locale: + missing_list = "\n".join(f" {key.message!r}" for key in missing[:10]) + more = f"\n ... and {len(missing) - 10} more" if len(missing) > 10 else "" + console.warn( + f"{len(missing)} translation(s) missing for locale {locale!r} " + f"(falling back to the default locale):\n{missing_list}{more}" + ) + messages_body = "\n".join(entries) + # A Map, not an object literal: a message named like a prototype member + # ("constructor", "__proto__") must miss rather than resolve to one. + return ( + f"{_GENERATED_HEADER}" + f"export const plural = (n) => Number({_plural_expr_js(catalog, locale)});\n" + f"export const messages = new Map([\n{messages_body}\n]);\n" + ) + + +def _lookup_translation( + catalog: Catalog | None, key: MessageKey +) -> str | list[str] | None: + """Find the translation for a message in a catalog. + + Args: + catalog: The parsed catalog, or None. + key: The message to look up. + + Returns: + The translated string, a list of plural forms, or None if the message + is untranslated (including partially translated plurals). + """ + if catalog is None: + return None + message = catalog.get(key.msgid, key.context) + # A fuzzy entry carries a stale translation; fall back to the source text + # (the server side already excludes fuzzy via write_mo). + if message is None or message.fuzzy: + return None + strings = message.string + if key.plural is None: + return strings or None if isinstance(strings, str) else None + if isinstance(strings, str): + strings = (strings,) + if not strings or not all(strings): + return None + return list(strings) + + +def default_plural_expr_js(catalog: Catalog | None, locale: str) -> str: + """The default locale's plural expression for the client-side fallback. + + Args: + catalog: The default locale's catalog, if one exists. + locale: The default locale. + + Returns: + A validated JS plural expression over the variable ``n``. + """ + if catalog is None: + from babel.messages.catalog import Catalog as BabelCatalog + + try: + # Babel's built-in CLDR plural table for the locale. + catalog = BabelCatalog(locale=locale.replace("-", "_")) + except ValueError: + return _FALLBACK_PLURAL_EXPR + return _plural_expr_js(catalog, locale) + + +def compile_index_module( + config: I18nConfig, + *, + url_routing: bool = False, + default_at_root: bool = True, + deploy_url: str = "", + default_plural_expr: str = _FALLBACK_PLURAL_EXPR, +) -> str: + """Render the JS module describing the app's locales and catalog loaders. + + The static ``import()`` map lets the bundler code-split one chunk per + locale, so clients only ever download the active language. + + Args: + config: The app's i18n configuration. + url_routing: Whether URL-based locale routing is enabled (so the client + knows the locale comes from the URL, not the cookie). + default_at_root: Whether URL routing serves the default locale at the + unprefixed path (used by the hreflang helper). + deploy_url: Absolute site URL for building absolute hreflang hrefs, or + empty to emit relative hrefs. + default_plural_expr: The default locale's plural expression, used + client-side to pick between the two source strings of a plural + message that has no catalog entry. + + Returns: + The JS module source code. + """ + loaders = "\n".join( + f" {json.dumps(locale)}: () => import({json.dumps(f'$/i18n/{locale}.js')})," + for locale in config.locales + ) + return ( + f"{_GENERATED_HEADER}" + f"export const locales = {json.dumps(list(config.locales))};\n" + f"export const defaultLocale = {json.dumps(config.default_locale)};\n" + f"export const cookieName = {json.dumps(LOCALE_COOKIE_NAME)};\n" + f"export const urlRouting = {json.dumps(url_routing)};\n" + f"export const defaultAtRoot = {json.dumps(default_at_root)};\n" + f"export const deployUrl = {json.dumps(deploy_url)};\n" + f"export const defaultPlural = (n) => Number({default_plural_expr});\n" + f"export const loaders = {{\n{loaders}\n}};\n" + ) diff --git a/packages/reflex-i18n/src/reflex_i18n/cli.py b/packages/reflex-i18n/src/reflex_i18n/cli.py new file mode 100644 index 00000000000..9e75efcfe33 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/cli.py @@ -0,0 +1,302 @@ +"""The ``reflex i18n`` command group: extract, init, and check catalogs. + +Registered lazily as ``reflex i18n`` by ``reflex.reflex`` when this package is +installed. +""" + +from __future__ import annotations + +import dataclasses +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import click +from reflex_base.config import get_config +from reflex_base.utils import console + +from .config import validate_locale +from .plugin import I18nPlugin +from .registry import MessageKey, collected_messages + +if TYPE_CHECKING: + from babel.messages.catalog import Catalog + +# Call names extracted for server-side (dynamic) translation. Matched +# syntactically by Babel, so the conventional ``gettext as _`` alias works. +_GETTEXT_KEYWORDS = { + "_": None, + "gettext": None, + "ngettext": (1, 2), + "pgettext": ((1, "c"), 2), +} + +_POT_FILENAME = "messages.pot" + + +@dataclasses.dataclass +class LocaleStats: + """Per-locale translation completeness, for reporting and ``check``.""" + + locale: str + missing: int = 0 + fuzzy: int = 0 + obsolete: int = 0 + + @property + def incomplete(self) -> bool: + """Whether the locale has untranslated or fuzzy messages. + + Returns: + True if any message is missing or fuzzy. + """ + return bool(self.missing or self.fuzzy) + + +def _resolve_plugin() -> I18nPlugin: + """Find the active I18nPlugin in the loaded config. + + Returns: + The configured plugin. + + Raises: + click.ClickException: If no I18nPlugin is configured. + """ + plugin = next((p for p in get_config().plugins if isinstance(p, I18nPlugin)), None) + if plugin is None: + msg = ( + "No I18nPlugin configured. Add I18nPlugin(locales=[...]) to " + "rx.Config(plugins=[...]) in rxconfig.py." + ) + raise click.ClickException(msg) + return plugin + + +def _extract_template() -> tuple[I18nPlugin, Catalog, Path]: + """Dry-compile the app and extract every message into a template catalog. + + Returns: + The plugin, the extracted template, and the catalog directory. + """ + from reflex.utils import prerequisites + + prerequisites.get_compiled_app(dry_run=True, use_rich=False) + plugin = _resolve_plugin() + template = extract_catalog(_app_source_dir(), collected_messages()) + return plugin, template, Path.cwd() / plugin.catalog_dir + + +def extract_catalog(app_dir: Path, used_messages: tuple[MessageKey, ...]) -> Catalog: + """Build a message-template catalog from both translation sources. + + Args: + app_dir: The app source directory to scan for gettext calls. + used_messages: Static ``rx.t`` messages from the compile registry. + + Returns: + A catalog holding every extracted message (untranslated template). + """ + from babel.messages.catalog import Catalog + from babel.messages.extract import extract_from_dir + + catalog = Catalog() + + # Dynamic content: gettext-family calls in the app source (with locations). + for filename, lineno, message, _comments, context in extract_from_dir( + app_dir, keywords=_GETTEXT_KEYWORDS + ): + catalog.add( + message, + locations=[(str(Path(app_dir.name) / filename), lineno)], + context=context, + ) + + # Static content: rx.t messages collected during compilation. + for key in used_messages: + catalog.add(key.msgid, context=key.context) + + return catalog + + +def _read_or_new_catalog(po_path: Path, locale: str) -> Catalog: + """Read an existing ``.po`` file or create an empty catalog for a locale. + + Args: + po_path: The path of the ``.po`` file. + locale: The locale identifier. + + Returns: + The loaded or newly created catalog. + """ + from babel.messages.catalog import Catalog + from babel.messages.pofile import read_po + + # Babel parses only the underscore form of a hyphenated tag (pt-BR). + babel_locale = locale.replace("-", "_") + if po_path.exists(): + with po_path.open("rb") as f: + return read_po(f, locale=babel_locale) + return Catalog(locale=babel_locale) + + +def _write_catalog(catalog: Catalog, path: Path) -> None: + """Write a catalog to a ``.po``/``.pot`` file. + + Args: + catalog: The catalog to write. + path: The destination path. + """ + from babel.messages.pofile import write_po + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + write_po(f, catalog, omit_header=False) + + +def merge_into_locale(template: Catalog, po_path: Path, locale: str) -> LocaleStats: + """Merge the template into a locale catalog, preserving translations. + + Args: + template: The freshly extracted message template. + po_path: The locale's ``.po`` file path. + locale: The locale identifier. + + Returns: + Stats describing the merged catalog. + """ + catalog = _read_or_new_catalog(po_path, locale) + catalog.update(template) + _write_catalog(catalog, po_path) + return _catalog_stats(catalog, locale) + + +def _catalog_stats(catalog: Catalog, locale: str) -> LocaleStats: + """Count untranslated, fuzzy, and obsolete messages in a catalog. + + Args: + catalog: The catalog to inspect. + locale: The locale identifier. + + Returns: + The computed stats. + """ + stats = LocaleStats(locale=locale, obsolete=len(catalog.obsolete)) + for message in catalog: + if not message.id: # the header entry + continue + if "fuzzy" in message.flags: + stats.fuzzy += 1 + elif not message.string or ( + isinstance(message.string, (list, tuple)) and not all(message.string) + ): + stats.missing += 1 + return stats + + +def _report(stats: LocaleStats, *, is_source: bool = False) -> None: + """Print a one-line summary for a locale. + + Args: + stats: The locale stats to report. + is_source: Whether this is the default locale, whose catalog holds the + source text and is expected to have empty ``msgstr`` entries. + """ + detail = f"{stats.missing} missing, {stats.fuzzy} fuzzy, {stats.obsolete} obsolete" + if is_source: + console.info(f" {stats.locale}: source catalog ({stats.obsolete} obsolete)") + elif stats.incomplete: + console.warn(f" {stats.locale}: {detail}") + else: + console.success(f" {stats.locale}: complete ({stats.obsolete} obsolete)") + + +def _app_source_dir() -> Path: + """The app's Python source directory. + + Resolved from the imported app module (the dry compile imported it), so + ``app_module_import`` and src layouts scan the real package; falls back to + ``/`` when the module cannot be found. + + Returns: + The directory scanned for gettext calls. + """ + config = get_config() + top_module = sys.modules.get(config.module.partition(".")[0]) + pkg_paths = getattr(top_module, "__path__", None) + if pkg_paths: + return Path(next(iter(pkg_paths))) + module_file = getattr(top_module, "__file__", None) + if module_file: + return Path(module_file).parent + return Path.cwd() / config.app_name + + +@click.group() +def i18n_cli(): + """Manage translation catalogs for the app.""" + + +@i18n_cli.command(name="extract") +def extract_command(): + """Extract messages and update every locale's ``.po`` catalog.""" + plugin, template, catalog_dir = _extract_template() + + _write_catalog(template, catalog_dir / _POT_FILENAME) + console.info(f"Extracted {len(template)} messages.") + for locale in plugin.locales: + _report( + merge_into_locale(template, catalog_dir / f"{locale}.po", locale), + is_source=locale == plugin.default_locale, + ) + console.success("Catalogs updated.") + + +@i18n_cli.command(name="init") +@click.argument("locale") +def init_command(locale: str): + """Create a new ``.po`` catalog for LOCALE. + + Args: + locale: The locale to initialize (e.g. ``de``). + + Raises: + ClickException: If the locale is not a language tag, or the catalog + already exists. + """ + try: + validate_locale(locale) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + plugin, template, catalog_dir = _extract_template() + po_path = catalog_dir / f"{locale}.po" + if po_path.exists(): + msg = f"Catalog already exists: {po_path}. Use `reflex i18n extract`." + raise click.ClickException(msg) + + stats = merge_into_locale(template, po_path, locale) + console.success(f"Created {po_path} with {stats.missing} messages to translate.") + if locale not in plugin.locales: + console.info( + f"Add {locale!r} to I18nPlugin(locales=[...]) in rxconfig.py to ship it." + ) + + +@i18n_cli.command(name="check") +def check_command(): + """Fail if any non-default locale has untranslated or fuzzy messages.""" + plugin, template, catalog_dir = _extract_template() + + incomplete = False + for locale in plugin.locales: + if locale == plugin.default_locale: + continue + catalog = _read_or_new_catalog(catalog_dir / f"{locale}.po", locale) + catalog.update(template) + stats = _catalog_stats(catalog, locale) + _report(stats) + incomplete = incomplete or stats.incomplete + + if incomplete: + msg = "Some locales have untranslated or fuzzy messages." + raise click.ClickException(msg) + console.success("All locales are complete.") diff --git a/packages/reflex-i18n/src/reflex_i18n/component.py b/packages/reflex-i18n/src/reflex_i18n/component.py new file mode 100644 index 00000000000..cfac69182a1 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/component.py @@ -0,0 +1,103 @@ +"""The client components backing i18n (provider, per-route locale, hreflang).""" + +from __future__ import annotations + +import functools +from typing import Any + +from reflex_base.components.component import Component +from reflex_base.event import EventHandler, passthrough_event_spec +from reflex_base.plugins.base import get_plugin +from reflex_base.vars.base import Var + +# Outside the ErrorBoundary (55) so error fallback UI can translate too. +_PROVIDER_PRIORITY = 58 + + +class I18nProvider(Component): + """Provides the active locale and message catalog via React context. + + Implemented in the static web template ``utils/i18n.js``; pulled into the + app shell automatically (via ``VarData.app_wraps``) whenever ``rx.t`` is + used. + """ + + library = "$/utils/i18n" + + tag = "I18nProvider" + + # Fires with the locale a user picks through the provider (cookie mode only). + on_locale_change: EventHandler[passthrough_event_spec(str)] + + +@functools.cache +def _provider() -> I18nProvider: + """The provider app wrap shared by every feature that needs it. + + In cookie mode it reports a client-side switch to ``I18nState`` so the + server's locale follows; the URL owns the locale otherwise. + + Returns: + The provider for the configured plugin. + """ + # Inline: the plugin module imports this one, and the state module + # registers I18nState on import. + from .plugin import I18nPlugin + + plugin = get_plugin(I18nPlugin) + if plugin is None or plugin.routing is not None: + return I18nProvider.create() + + from .state import I18nState + + return I18nProvider.create(on_locale_change=I18nState.set_locale) + + +class LocaleRoute(Component): + """Wraps a per-locale route with a fixed locale + static catalog. + + The static catalog import makes the language available synchronously during + prerender (unlike the provider's default dynamic import). + """ + + library = "$/utils/i18n" + + tag = "LocaleRoute" + + # The locale this route renders in. + locale: Var[str] + + # The statically-imported catalog module for ``locale``. + catalog: Var[Any] + + +class HreflangLinks(Component): + """App-wrap emitting ``hreflang`` alternates + canonical for the route. + + Reads the current path and its config from ``$/i18n/index.js`` (no props). + When every locale is prefixed, it also redirects an unprefixed URL to its + default-locale address. + """ + + library = "$/utils/i18n" + + tag = "HreflangLinks" + + +class LanguageSwitcher(Component): + """A language switcher: one ```` per locale (crawlable with URL routing).""" + + library = "$/utils/i18n" + + tag = "LanguageSwitcher" + + @staticmethod + def _get_app_wrap_components() -> dict[tuple[int, str], Component]: + """Pull in the provider so the switcher can read and set the locale. + + Returns: + The provider app wrap. + """ + # A page may use the switcher without any rx.t or rx.i18n formatting + # var, neither of which would then drag the provider in. + return {(_PROVIDER_PRIORITY, "I18nProvider"): _provider()} diff --git a/packages/reflex-i18n/src/reflex_i18n/config.py b/packages/reflex-i18n/src/reflex_i18n/config.py new file mode 100644 index 00000000000..b17e255ffb4 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/config.py @@ -0,0 +1,138 @@ +"""App-level i18n configuration.""" + +from __future__ import annotations + +import dataclasses +import re +from collections.abc import Sequence +from pathlib import Path + +# Cookie persisting the user's chosen locale; read by both the client +# runtime and the server-side locale resolution. +LOCALE_COOKIE_NAME = "reflex_locale" + +# A BCP 47-shaped tag: alphabetic language, optional alphanumeric subtags. +# Locales double as URL path segments, JS catalog identifiers and `Intl` / +# `Accept-Language` tags, all of which are hyphen-separated, so the underscore +# spelling is rejected rather than normalized in each of those places. Babel's +# underscore form is derived internally where it is needed. +_LOCALE_RE = re.compile(r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$") + + +def validate_locale(locale: str) -> None: + """Check that a locale is a usable language tag. + + Args: + locale: The locale to check. + + Raises: + ValueError: If the locale is not a hyphen-separated language tag. + """ + if not _LOCALE_RE.fullmatch(locale): + msg = ( + f"Invalid locale {locale!r}: expected a hyphen-separated language " + "tag such as 'en', 'pt-BR' or 'zh-Hant'." + ) + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class I18nConfig: + """Internationalization configuration held by :class:`I18nPlugin`.""" + + # Locales the app supports, e.g. ("en", "de"). Order is preserved for + # Accept-Language negotiation ties. + locales: tuple[str, ...] + + # The locale the source-text msgids are written in. + default_locale: str = "en" + + # Directory (relative to the app root) containing {locale}.po catalogs. + catalog_dir: str = "locales" + + def __init__( + self, + locales: Sequence[str], + default_locale: str = "en", + catalog_dir: str = "locales", + ): + """Initialize and validate the i18n configuration. + + Args: + locales: Locales the app supports, e.g. ``["en", "de"]``. + default_locale: The locale the source-text msgids are written in. + catalog_dir: Directory (relative to the app root) containing + ``{locale}.po`` catalogs. + + Raises: + ValueError: If no locales are given, a locale is repeated, a locale + is not a language tag, or the default locale is not among them. + """ + locales_tuple = tuple(locales) + if not locales_tuple: + msg = "I18nConfig.locales must contain at least one locale." + raise ValueError(msg) + if len(set(locales_tuple)) != len(locales_tuple): + # A repeated locale would emit its route and catalog module twice. + msg = f"I18nConfig.locales must not repeat a locale: {locales_tuple!r}." + raise ValueError(msg) + for locale in locales_tuple: + validate_locale(locale) + if default_locale not in locales_tuple: + msg = ( + f"I18nConfig.default_locale {default_locale!r} must be one of " + f"the configured locales {locales_tuple!r}." + ) + raise ValueError(msg) + object.__setattr__(self, "locales", locales_tuple) + object.__setattr__(self, "default_locale", default_locale) + object.__setattr__(self, "catalog_dir", catalog_dir) + + +_active_config: I18nConfig | None = None + +# Absolute catalog directory, captured when the app is constructed (cwd is the +# app root then). Used at compile and request time so catalog loading does not +# depend on the process cwd, which is not guaranteed to be the app root later. +_active_catalog_dir: Path | None = None + + +def set_active_i18n_config(config: I18nConfig | None) -> None: + """Set the i18n configuration of the running app. + + Called by ``rx.App`` so server-side translation helpers can resolve + locales without a reference to the app instance. Must be called while the + current working directory is the app root (it is during app construction). + + Args: + config: The configuration to activate, or None to deactivate. + """ + # Inline to avoid a circular import; runtime imports from this module. + from .runtime import clear_translations_cache + + global _active_config, _active_catalog_dir + _active_config = config + _active_catalog_dir = ( + (Path.cwd() / config.catalog_dir).resolve() if config is not None else None + ) + # Drop translators built from the previous catalog dir (or from catalogs + # edited before a dev hot reload re-activated the config). + clear_translations_cache() + + +def get_active_i18n_config() -> I18nConfig | None: + """Get the i18n configuration of the running app. + + Returns: + The active configuration, or None if the app has no i18n config. + """ + return _active_config + + +def get_active_catalog_dir() -> Path | None: + """Get the absolute catalog directory of the running app. + + Returns: + The absolute catalog directory, or None if i18n is not configured. + """ + return _active_catalog_dir diff --git a/packages/reflex-i18n/src/reflex_i18n/format.py b/packages/reflex-i18n/src/reflex_i18n/format.py new file mode 100644 index 00000000000..a91a7937cc8 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/format.py @@ -0,0 +1,364 @@ +"""Client-side, locale-aware number and date formatting vars. + +``rx.i18n.number`` / ``rx.i18n.currency`` / ``rx.i18n.date`` (and friends) +format a value in the active locale using the browser's ``Intl`` API, +reactively reformatting when the locale changes. To format inside state +(server-side), use the ``format_*`` helpers in :mod:`reflex_i18n.runtime`. +""" + +from __future__ import annotations + +import functools +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import LiteralVar, Var, VarData +from reflex_base.vars.function import FunctionStringVar, FunctionVar, ReflexCallable +from reflex_base.vars.sequence import StringVar + +from .component import _PROVIDER_PRIORITY, _provider +from .config import get_active_i18n_config + +if TYPE_CHECKING: + # Aliased so the `date`/`time`/`datetime` functions below don't shadow it. + import datetime as _datetime + + +def _require_config() -> None: + """Ensure the i18n plugin is configured. + + Raises: + RuntimeError: If no I18nPlugin is configured. + """ + if get_active_i18n_config() is None: + msg = ( + "rx.i18n formatting requires the i18n plugin. Add " + "I18nPlugin(locales=[...]) to rx.Config(plugins=[...])." + ) + raise RuntimeError(msg) + + +@functools.cache +def _format_var_data() -> VarData: + """VarData injecting the ``useFormat`` hook and the provider. + + Returns: + The shared VarData for number/date formatting vars. + """ + return VarData( + imports={"$/utils/i18n": [ImportVar(tag="useFormat")]}, + hooks={"const [ fmtNumber, fmtDate ] = useFormat()": None}, + app_wraps=((_PROVIDER_PRIORITY, _provider()),), + ) + + +@functools.cache +def _locale_var_data() -> VarData: + """VarData injecting the ``useLocale`` hook and the provider. + + Returns: + The shared VarData for the active-locale var. + """ + return VarData( + imports={"$/utils/i18n": [ImportVar(tag="useLocale")]}, + hooks={"const i18nLocale = useLocale()": None}, + app_wraps=((_PROVIDER_PRIORITY, _provider()),), + ) + + +# ``useFormat`` destructures to ``[fmtNumber, fmtDate]``; each takes the value +# to format and an ``Intl`` options object, and returns the formatted string. +_Formatter = ReflexCallable[[Any, Mapping[str, Any]], str] + + +@functools.cache +def _fmt_number() -> FunctionVar[_Formatter]: + """The client-side number formatter for the active locale. + + Returns: + The ``fmtNumber`` hook function as a callable Var. + """ + return FunctionStringVar.create( + "fmtNumber", _var_type=_Formatter, _var_data=_format_var_data() + ) + + +@functools.cache +def _fmt_date() -> FunctionVar[_Formatter]: + """The client-side date formatter for the active locale. + + Returns: + The ``fmtDate`` hook function as a callable Var. + """ + return FunctionStringVar.create( + "fmtDate", _var_type=_Formatter, _var_data=_format_var_data() + ) + + +def _number_options( + *, + style: str | None = None, + currency: str | None = None, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + compact: bool = False, + options: dict[str, Any] | None = None, +) -> Var[Mapping[str, Any]]: + """Build the ``Intl.NumberFormat`` options object from curated kwargs. + + Args: + style: The Intl number style (``decimal``/``currency``/``percent``). + currency: The ISO 4217 currency code (for ``style="currency"``). + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping (thousands) separator. + compact: Whether to use compact notation (e.g. ``1.2M``). + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + The Intl options object as a Var. + """ + opts: dict[str, Any] = {} + if style is not None: + opts["style"] = style + if currency is not None: + opts["currency"] = currency + if min_fraction_digits is not None: + opts["minimumFractionDigits"] = min_fraction_digits + if max_fraction_digits is not None: + opts["maximumFractionDigits"] = max_fraction_digits + if grouping is not None: + opts["useGrouping"] = grouping + if compact: + opts["notation"] = "compact" + if options: + opts.update(options) + return LiteralVar.create(opts) + + +def number( + value: Var[Any] | int | float, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + compact: bool = False, + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a number in the active locale. + + Args: + value: The number to format. + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + compact: Whether to use compact notation (e.g. ``1.2M``). + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized number. + """ + _require_config() + opts = _number_options( + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + compact=compact, + options=options, + ) + return _fmt_number().call(value, opts).to(str) + + +def currency( + value: Var[Any] | int | float, + currency: str, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + compact: bool = False, + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a currency amount in the active locale. + + Args: + value: The amount to format. + currency: The ISO 4217 currency code (e.g. ``"EUR"``). + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + compact: Whether to use compact notation. + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized currency amount. + """ + _require_config() + opts = _number_options( + style="currency", + currency=currency, + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + compact=compact, + options=options, + ) + return _fmt_number().call(value, opts).to(str) + + +def percent( + value: Var[Any] | int | float, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a ratio as a percentage in the active locale (``0.15`` -> ``15%``). + + Args: + value: The ratio to format (``1`` == 100%). + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized percentage. + """ + _require_config() + opts = _number_options( + style="percent", + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + options=options, + ) + return _fmt_number().call(value, opts).to(str) + + +# ``Intl.DateTimeFormat`` throws when ``dateStyle``/``timeStyle`` are combined +# with any of these per-component options, so the curated ``length`` gives way +# when the escape hatch spells the format out field by field. +_DATE_COMPONENT_OPTIONS = frozenset({ + "weekday", + "era", + "year", + "month", + "day", + "dayPeriod", + "hour", + "minute", + "second", + "fractionalSecondDigits", + "timeZoneName", +}) + + +def _date_options( + *, + date_style: str | None = None, + time_style: str | None = None, + options: dict[str, Any] | None = None, +) -> Var[Mapping[str, Any]]: + """Build the ``Intl.DateTimeFormat`` options object from curated kwargs. + + Args: + date_style: The Intl ``dateStyle`` (``short``/``medium``/``long``/``full``). + time_style: The Intl ``timeStyle``. + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + The Intl options object as a Var. + """ + opts: dict[str, Any] = {} + if not (options and _DATE_COMPONENT_OPTIONS.intersection(options)): + if date_style is not None: + opts["dateStyle"] = date_style + if time_style is not None: + opts["timeStyle"] = time_style + if options: + opts.update(options) + return LiteralVar.create(opts) + + +def date( + value: Var[Any] | _datetime.date | str, + *, + length: str = "medium", + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a date in the active locale. + + Args: + value: The date to format (a Var or ISO string). + length: The date length (``short``/``medium``/``long``/``full``). + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized date. + """ + _require_config() + return ( + _fmt_date() + .call(value, _date_options(date_style=length, options=options)) + .to(str) + ) + + +def time( + value: Var[Any] | _datetime.time | str, + *, + length: str = "medium", + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a time in the active locale. + + Args: + value: The time to format (a Var or ISO string). + length: The time length (``short``/``medium``/``long``/``full``). + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized time. + """ + _require_config() + return ( + _fmt_date() + .call(value, _date_options(time_style=length, options=options)) + .to(str) + ) + + +def datetime( + value: Var[Any] | _datetime.datetime | str, + *, + length: str = "medium", + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a date and time in the active locale. + + Args: + value: The datetime to format (a Var or ISO string). + length: The length (``short``/``medium``/``long``/``full``). + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized date and time. + """ + _require_config() + opts = _date_options(date_style=length, time_style=length, options=options) + return _fmt_date().call(value, opts).to(str) + + +@functools.cache +def _locale_var() -> StringVar: + """Build the active-locale var (cached singleton). + + Returns: + A StringVar resolving to the active locale code. + """ + _require_config() + return Var(_js_expr="i18nLocale", _var_data=_locale_var_data()).to(str) diff --git a/packages/reflex-i18n/src/reflex_i18n/nav.py b/packages/reflex-i18n/src/reflex_i18n/nav.py new file mode 100644 index 00000000000..b2f75a10e15 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/nav.py @@ -0,0 +1,57 @@ +"""Navigation helpers for URL-based locale routing (``rx.i18n.*``).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .component import LanguageSwitcher + +if TYPE_CHECKING: + from reflex_base.components.component import Component + + +def _base_path(route: str) -> str: + """Normalize a route to a base URL path. + + Args: + route: A route key (``"index"``, ``"pricing"``) or path (``"/pricing"``). + + Returns: + The URL path (``"/"`` for the index, else ``"/"``). + """ + if route in ("", "/", "index"): + return "/" + return route if route.startswith("/") else f"/{route}" + + +def locale_url(locale: str, route: str) -> str: + """The URL path for a route in a given locale (for custom links). + + Args: + locale: The target locale. + route: The base route (``"/pricing"`` or ``"pricing"``). + + Returns: + The localized URL path (unchanged if URL routing is off). + """ + from reflex_base.plugins.base import get_plugin + + from .plugin import I18nPlugin + + path = _base_path(route) + plugin = get_plugin(I18nPlugin) + if plugin is None or plugin.routing is None: + return path + return plugin.routing.localize(path, locale, plugin.default_locale) + + +def language_switcher(**props: Any) -> Component: + """A crawlable language switcher: one ```` link per locale. + + Args: + props: Props forwarded to the switcher's ``