diff --git a/.github/scripts/dispatch_release/detect.sh b/.github/scripts/dispatch_release/detect.sh index 2f472ca7434..6893b2f404b 100755 --- a/.github/scripts/dispatch_release/detect.sh +++ b/.github/scripts/dispatch_release/detect.sh @@ -18,8 +18,9 @@ declare -A MAP=( [reflex_components_sonner]=reflex-components-sonner [reflex_docgen]=reflex-docgen [reflex_hosting_cli]=reflex-hosting-cli + [reflex_i18n]=reflex-i18n ) -ORDER=(hatch_reflex_pyi reflex_base reflex_components_code reflex_components_core reflex_components_dataeditor reflex_components_gridjs reflex_components_lucide reflex_components_markdown reflex_components_moment reflex_components_plotly reflex_components_radix reflex_components_react_player reflex_components_recharts reflex_components_sonner reflex_docgen reflex_hosting_cli) +ORDER=(hatch_reflex_pyi reflex_base reflex_components_code reflex_components_core reflex_components_dataeditor reflex_components_gridjs reflex_components_lucide reflex_components_markdown reflex_components_moment reflex_components_plotly reflex_components_radix reflex_components_react_player reflex_components_recharts reflex_components_sonner reflex_docgen reflex_hosting_cli reflex_i18n) PACKAGES=() for key in "${ORDER[@]}"; do diff --git a/.github/workflows/dispatch_release.yml b/.github/workflows/dispatch_release.yml index e7b6d426136..bdefeef7695 100644 --- a/.github/workflows/dispatch_release.yml +++ b/.github/workflows/dispatch_release.yml @@ -129,6 +129,10 @@ on: description: "reflex-hosting-cli" type: boolean default: false + reflex_i18n: + description: "reflex-i18n" + type: boolean + default: false permissions: contents: read @@ -166,6 +170,7 @@ jobs: reflex_components_sonner: ${{ inputs.reflex_components_sonner }} reflex_docgen: ${{ inputs.reflex_docgen }} reflex_hosting_cli: ${{ inputs.reflex_hosting_cli }} + reflex_i18n: ${{ inputs.reflex_i18n }} run: bash .github/scripts/dispatch_release/detect.sh materialize: 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 1231a58c316..6fd44639251 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 @@ -123,6 +123,7 @@ def get_sidebar_items_backend(): client_storage, database, events, + i18n, state, state_structure, utility_methods, @@ -179,6 +180,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..7d43018b42c --- /dev/null +++ b/docs/i18n/overview.md @@ -0,0 +1,329 @@ +```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)) +``` + +### 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 +def price_row(): + return rx.hstack( + rx.text(rx.i18n.number(State.quantity, max_fraction_digits=2)), + rx.text(rx.i18n.currency(State.total, "EUR")), + rx.text(rx.i18n.percent(State.tax_rate, max_fraction_digits=1)), + rx.text(rx.i18n.date(State.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(State.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. + +## 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. + +Add a crawlable switcher (real `` links, not a cookie swap), or build your +own links 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` + the unprefixed path and the prefixed default (`/pricing` and `/en/pricing`) + both exist — add a redirect from the unprefixed path if you use that mode. + +## 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 locale has untranslated or fuzzy messages. +# 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/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..c134b4e010b --- /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=[...])`. Installed packages can now also contribute `reflex` CLI subcommands by declaring a `reflex.cli` entry point (e.g. `reflex-i18n` adds `reflex i18n`); a plugin failing to load its command no longer breaks the whole CLI. diff --git a/packages/reflex-base/news/6796.feature.md b/packages/reflex-base/news/6796.feature.md new file mode 100644 index 00000000000..f7a749368a1 --- /dev/null +++ b/packages/reflex-base/news/6796.feature.md @@ -0,0 +1 @@ +Added two 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; and `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). Both 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 f361b2f3969..8ba129c2db7 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 @@ -17,6 +17,7 @@ from reflex.utils import console, types from reflex_base.event.context import EventContext from reflex_base.event.processor.event_processor import EventProcessor, EventQueueEntry +from reflex_base.event.processor.scope import event_scope from reflex_base.registry import RegisteredEventHandler from reflex_base.utils.format import format_event_handler @@ -207,8 +208,12 @@ async def chain_updates( if root_state is not None: # Emit deltas first, so any frontend events are processed with 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. 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: @@ -374,25 +379,30 @@ async def _execute_event( substate = await state.get_state(event.state_cls) 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. - await process_event( - handler=registered_handler.handler, - state=StateProxy(substate), - payload=event.payload, - root_state=root_state, - ) + async with event_scope(root_state): + await process_event( + handler=registered_handler.handler, + state=StateProxy(substate), + payload=event.payload, + root_state=root_state, + ) async def _handle_backend_exception( self, ex: Exception, ev_ctx: EventContext | None = None 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..eee9a50aee2 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/event/processor/scope.py @@ -0,0 +1,82 @@ +"""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 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: + # ``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(self._root_state)) + except BaseException: + self._stack.close() + raise + + async def __aexit__(self, *exc_info: object) -> None: + self._stack.close() + + +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/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 7f2a9fd5c7a..cd52054e215 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, 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. @@ -216,6 +237,28 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None: instruction.argval ) + 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. @@ -454,6 +497,23 @@ def _populate_dependencies(self) -> None: tracked_locals=self.tracked_locals, ) ) + 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). + self._add_implicit_dependency( + self._get_globals().get(instruction.argval) + ) + elif ( + instruction.opname == "LOAD_DEREF" + and self.scan_status == ScanStatus.SCANNING + ): + # Same as above for a closure-captured function reference. + self._add_implicit_dependency( + self._get_closure().get(instruction.argval) + ) elif instruction.opname == "IMPORT_NAME" and instruction.argval is not None: self.scan_status = ScanStatus.GETTING_IMPORT self._last_import_name = instruction.argval 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..7ecc4f16ff9 --- /dev/null +++ b/packages/reflex-i18n/pyproject.toml @@ -0,0 +1,32 @@ +[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.10" +dependencies = [ + # TODO: Bump to the released versions before publishing. + "reflex-base >= 0.9.7.dev0", + "reflex >= 0.9.7.dev0", + "babel >= 2.14.0,<3.0", +] + +[project.entry-points."reflex.cli"] +i18n = "reflex_i18n.cli:i18n_cli" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +pattern-prefix = "reflex-i18n-" +fallback-version = "0.0.0dev0" + +[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..47193472055 --- /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 LocaleRouting, 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. +__all__ = [ + "LOCALE_COOKIE_NAME", + "I18nConfig", + "I18nPlugin", + "I18nState", + "LocaleRouting", + "PathPrefixRouting", + "currency", + "format_currency", + "format_date", + "format_datetime", + "format_decimal", + "format_number", + "format_percent", + "format_time", + "gettext", + "language_switcher", + "locale", + "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`` entry point) 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..6f3c28813ea --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js @@ -0,0 +1,362 @@ +import { + createContext, + createElement, + Fragment, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { useLocation } from "react-router"; + +import { + cookieName, + defaultAtRoot, + defaultLocale, + 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"). +const negotiate = (requested) => { + for (const tag of requested) { + if (locales.includes(tag)) { + return tag; + } + const language = tag.split("-")[0]; + const match = locales.find( + (supported) => supported.split("-")[0] === language, + ); + 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) { + if (_switchLocale) { + _switchLocale(locale); + } +} + +export function I18nProvider({ children }) { + // Start 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 [locale, setLocaleState] = useState(defaultLocale); + const [catalog, setCatalog] = useState(undefined); + + useEffect(() => { + setLocaleState(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) + // leaves the previous catalog in place; text falls back to the source + // msgids. Surface it instead of an unhandled rejection. + console.error( + `Failed to load i18n catalog for locale "${locale}".`, + error, + ); + }); + // With URL-based routing the per-route LocaleRoute owns /dir + // (its locale is authoritative); only manage it here in cookie mode to + // avoid the two fighting over the document element. + if (!urlRouting) { + 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) => { + if (!locales.includes(nextLocale)) { + console.error( + `Invalid locale "${nextLocale}". Supported locales: ${locales.join(", ")}.`, + ); + 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); + setLocaleState(nextLocale); + }, []); + + useEffect(() => { + _switchLocale = setLocale; + return () => { + _switchLocale = null; + }; + }, [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[key] ?? stripContext(key); + return interpolate(message, params); + }, + [catalog], + ); + + const tp_ = useCallback( + (key, pluralMessage, count, params) => { + const entry = catalog?.messages[key]; + const message = Array.isArray(entry) + ? (entry[catalog.plural(count)] ?? entry[entry.length - 1]) + : count === 1 + ? 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 }) { + 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: switchLocale } }, + children, + ); +} + +// Path-prefix helpers (mirror reflex_i18n.routing.PathPrefixRouting). +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; +}; + +// 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 base = delocalizePath(pathname); + const links = locales.map((locale) => + createElement("link", { + key: locale, + rel: "alternate", + hrefLang: locale, + href: absoluteUrl(localizePath(base, locale)), + }), + ); + links.push( + createElement("link", { + key: "x-default", + rel: "alternate", + hrefLang: "x-default", + href: absoluteUrl(localizePath(base, defaultLocale)), + }), + createElement("link", { + key: "canonical", + rel: "canonical", + href: absoluteUrl(pathname), + }), + ); + // This is an app-wrap: render the links AND pass the app content through. + return createElement(Fragment, null, ...links, children); +} + +// A crawlable language switcher: real links to the current page in each +// locale (so crawlers follow them and the URL stays the source of truth). +export function LanguageSwitcher(props) { + const { pathname } = useLocation(); + const base = delocalizePath(pathname); + const active = locales.find( + (locale) => localizePath(base, locale) === pathname, + ); + return createElement( + "nav", + props, + ...locales.map((locale) => + createElement( + "a", + { + key: locale, + href: localizePath(base, locale), + hrefLang: locale, + "aria-current": locale === active ? "true" : undefined, + }, + 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..127dff832f9 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/catalog.py @@ -0,0 +1,170 @@ +"""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) -> Catalog: + """Parse a ``.po`` catalog file. + + Args: + path: The path of the ``.po`` file. + + Returns: + The parsed catalog. + """ + from babel.messages.pofile import read_po + + with path.open("r", encoding="utf-8") as po_file: + 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) + return ( + f"{_GENERATED_HEADER}" + f"export const plural = (n) => Number({_plural_expr_js(catalog, locale)});\n" + f"export const messages = {{\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) + if message is None: + 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 compile_index_module( + config: I18nConfig, + *, + url_routing: bool = False, + default_at_root: bool = True, + deploy_url: str = "", +) -> 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. + + 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 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..c241ae70864 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/cli.py @@ -0,0 +1,273 @@ +"""The ``reflex i18n`` command group: extract, init, and check catalogs. + +Attached to the ``reflex`` CLI via the ``reflex.cli`` entry point. +""" + +from __future__ import annotations + +import dataclasses +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 .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 + + if po_path.exists(): + with po_path.open("rb") as f: + return read_po(f, locale=locale) + return Catalog(locale=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) -> None: + """Print a one-line summary for a locale. + + Args: + stats: The locale stats to report. + """ + detail = f"{stats.missing} missing, {stats.fuzzy} fuzzy, {stats.obsolete} obsolete" + if 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. + + Returns: + The directory scanned for gettext calls. + """ + return Path.cwd() / get_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)) + 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 catalog already exists. + """ + 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..5f445317d54 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/component.py @@ -0,0 +1,58 @@ +"""The client components backing i18n (provider, per-route locale, hreflang).""" + +from __future__ import annotations + +from typing import Any + +from reflex_base.components.component import Component +from reflex_base.vars.base import Var + + +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" + + +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). + """ + + library = "$/utils/i18n" + + tag = "HreflangLinks" + + +class LanguageSwitcher(Component): + """A crawlable language switcher: one ```` link per locale.""" + + library = "$/utils/i18n" + + tag = "LanguageSwitcher" 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..1f856d6945c --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/config.py @@ -0,0 +1,101 @@ +"""App-level i18n configuration.""" + +from __future__ import annotations + +import dataclasses +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" + + +@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 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 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. + """ + 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 + ) + + +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..2402aa5cb68 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/format.py @@ -0,0 +1,335 @@ +"""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 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 FunctionVar +from reflex_base.vars.sequence import StringVar + +from .component import I18nProvider +from .config import get_active_i18n_config +from .vars import _PROVIDER_PRIORITY + +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, I18nProvider.create()),), + ) + + +@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, I18nProvider.create()),), + ) + + +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, +) -> dict[str, Any]: + """Build ``Intl.NumberFormat`` options 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. + """ + 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 opts + + +def _call(fn_name: str, value: Any, options: dict[str, Any]) -> StringVar: + """Call a client formatter hook function with a value and options. + + Args: + fn_name: The hook function (``fmtNumber`` or ``fmtDate``). + value: The value to format (may be a Var). + options: The Intl options object. + + Returns: + A StringVar resolving to the formatted value. + """ + var_data = _format_var_data() + formatter = Var(_js_expr=fn_name, _var_data=var_data).to(FunctionVar) + return formatter.call(value, LiteralVar.create(options)).to(str) + + +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() + return _call( + "fmtNumber", + value, + _number_options( + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + compact=compact, + options=options, + ), + ) + + +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() + return _call( + "fmtNumber", + value, + _number_options( + style="currency", + currency=currency, + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + compact=compact, + options=options, + ), + ) + + +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() + return _call( + "fmtNumber", + value, + _number_options( + style="percent", + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + options=options, + ), + ) + + +def _date_options( + *, + date_style: str | None = None, + time_style: str | None = None, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build ``Intl.DateTimeFormat`` options 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. + """ + opts: dict[str, Any] = {} + 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 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 _call("fmtDate", value, _date_options(date_style=length, options=options)) + + +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 _call("fmtDate", value, _date_options(time_style=length, options=options)) + + +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() + return _call( + "fmtDate", + value, + _date_options(date_style=length, time_style=length, options=options), + ) + + +@functools.cache +def _locale_var() -> StringVar: + """Build the active-locale var (cached singleton). + + Returns: + A StringVar resolving to the active locale code. + """ + 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 ``