Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ class OrdersModule(ModuleBase):

The key (`"orders"`) is the **namespace** — it prefixes every key in the files. `smpy create-module` scaffolds this method and a starter `en.json` automatically.

### Audience

Every module's catalog ships inside the Inertia shared props on full page
loads. A module whose UI sits entirely behind login can declare that:

```python
meta = ModuleMeta(name="Orders", ..., i18n_audience="admin")
```

`"admin"` catalogs are withheld from anonymous visitors — a public content
page stops paying for settings-form labels it can never render — and shipped
as soon as the user authenticates (the login transition re-sends the bundle
even on an Inertia partial). The default is `"public"`: ship to everyone.
Server-side `Translator` lookups always see every namespace regardless.
The framework's own admin modules (settings, permissions, dashboard,
file_storage, audit_log, feature_flags, background_tasks, branding) declare
`"admin"`.

## Key naming

Keys are `<namespace>.<area>.<string>` with hierarchical JSON objects that flatten at boot:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SimpleModule</title>
{# branding_head reads the branding module's settings (when installed) so
crawlers and link previews see the configured site name before React
hydrates; Inertia's <Head> takes over per-page titles after that. #}
{% set brand = branding_head(request) %}
<title>{{ brand.app_name }}</title>
{% if brand.theme_color %}<meta name="theme-color" content="{{ brand.theme_color }}" />{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
Expand Down
37 changes: 28 additions & 9 deletions framework/core/simple_module_core/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class I18nRegistry:
def __init__(self, default_locale: str, supported_locales: list[str]) -> None:
self.default_locale = default_locale
self.supported_locales = list(supported_locales)
self._sources: list[tuple[str, Path]] = []
self._sources: list[tuple[str, Path, str]] = []
self._messages: dict[str, dict[str, str]] = {}
# Immutable views into ``_messages`` — handed out by ``messages()`` to
# avoid a per-call dict copy. Rebuilt whenever ``load()`` runs.
Expand All @@ -88,15 +88,22 @@ def __init__(self, default_locale: str, supported_locales: list[str]) -> None:
# avoids per-request dict copies that used to dominate allocations on the
# Inertia render path.
self._message_snapshots: dict[str, dict[str, str]] = {}
self._public_snapshots: dict[str, dict[str, str]] = {}
self._available_locales: tuple[str, ...] = ()
self._available_locales_list: list[str] = []
self._empty_view: MappingProxyType[str, str] = MappingProxyType({})
self._empty_snapshot: dict[str, str] = {}
self._loaded = False

def add_source(self, namespace: str, locale_dir: Path) -> None:
"""Queue a module's locale directory for loading under a namespace."""
self._sources.append((namespace, Path(locale_dir)))
def add_source(self, namespace: str, locale_dir: Path, *, audience: str = "public") -> None:
"""Queue a module's locale directory for loading under a namespace.

``audience="admin"`` keeps the namespace out of the public snapshot
(:meth:`messages_snapshot` with ``include_admin=False``) so catalogs
for login-gated UI aren't shipped to anonymous visitors. Server-side
lookups (:meth:`messages`) always see every namespace.
"""
self._sources.append((namespace, Path(locale_dir), audience))

def load(self) -> None:
"""Read and flatten all registered JSON files.
Expand All @@ -105,8 +112,11 @@ def load(self) -> None:
warning but do not raise. Malformed JSON raises ValueError.
"""
self._messages = {locale: {} for locale in self.supported_locales}
public_messages: dict[str, dict[str, str]] = {
locale: {} for locale in self.supported_locales
}

for namespace, locale_dir in self._sources:
for namespace, locale_dir, audience in self._sources:
for locale in self.supported_locales:
path = locale_dir / f"{locale}.json"
if not path.is_file():
Expand All @@ -124,15 +134,19 @@ def load(self) -> None:
raise ValueError(f"{path} must contain a JSON object at the top level")
flat = flatten_messages(raw, prefix=namespace)
self._messages[locale].update(flat)
if audience != "admin":
public_messages[locale].update(flat)

# Cache the derived views now that loading is complete. Downstream
# (middleware, translator, switcher) reads these on every request.
self._message_views = {
locale: MappingProxyType(msgs) for locale, msgs in self._messages.items()
}
# Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs
# once here rather than on every Inertia render.
# once here rather than on every Inertia render. The public variant
# (admin namespaces excluded) is what anonymous visitors receive.
self._message_snapshots = {locale: dict(msgs) for locale, msgs in self._messages.items()}
self._public_snapshots = public_messages
self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs)
self._available_locales_list = list(self._available_locales)
self._loaded = True
Expand Down Expand Up @@ -165,20 +179,25 @@ def messages(self, locale: str) -> Mapping[str, str]:
return self._empty_view
return MappingProxyType(raw)

def messages_snapshot(self, locale: str) -> dict[str, str]:
def messages_snapshot(self, locale: str, *, include_admin: bool = True) -> dict[str, str]:
"""Plain-dict snapshot for callers that JSON-serialize the result.

Built once at :meth:`load` time and handed out by reference on every
call. Callers must treat it as read-only — mutating the returned dict
corrupts subsequent responses. Used by the Inertia shared-props builder
where it sits on the request hot path; prior to this method,
``dict(messages(locale))`` per request was the top own-code allocator.

``include_admin=False`` returns the variant without ``audience="admin"``
namespaces — what anonymous visitors are served.
"""
snapshot = self._message_snapshots.get(locale)
pool = self._message_snapshots if include_admin else self._public_snapshots
snapshot = pool.get(locale)
if snapshot is not None:
return snapshot
# Fallback for tests that skip ``load()``: synthesize the snapshot on
# demand from whatever ``_messages`` holds.
# demand from whatever ``_messages`` holds (audience information only
# exists for sources that went through ``load()``).
raw = self._messages.get(locale)
if raw is None:
return self._empty_snapshot
Expand Down
9 changes: 9 additions & 0 deletions framework/core/simple_module_core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ class ModuleMeta:
installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
When ``None``, no compatibility check is performed (legacy modules).
"""
i18n_audience: str = "public"
"""Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.

``"public"`` (the default) ships the catalog in every Inertia payload.
``"admin"`` ships it only to authenticated users — declare it on modules
whose UI sits entirely behind login (settings, permissions, dashboards) so
anonymous visitors don't download admin form labels on every public page.
The catalog is always available server-side (``Translator``) either way.
"""


class ModuleBase(ABC):
Expand Down
23 changes: 23 additions & 0 deletions framework/core/tests/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ def test_available_locales_reports_loaded(self, tmp_path: Path) -> None:
reg.load()
assert sorted(reg.available_locales()) == ["en", "es"]

def test_admin_sources_are_excluded_from_the_public_snapshot(self, tmp_path: Path) -> None:
"""Anonymous visitors must not download catalogs for login-gated UI."""
self._write_locale(tmp_path / "pages", "en", {"title": "Pages"})
self._write_locale(tmp_path / "settings", "en", {"form": {"key": "Key"}})
reg = I18nRegistry(default_locale="en", supported_locales=["en"])
reg.add_source("pages", tmp_path / "pages")
reg.add_source("settings", tmp_path / "settings", audience="admin")
reg.load()
assert reg.messages_snapshot("en", include_admin=False) == {"pages.title": "Pages"}
# The full snapshot and server-side lookups still see everything.
assert reg.messages_snapshot("en") == {
"pages.title": "Pages",
"settings.form.key": "Key",
}
assert reg.messages("en")["settings.form.key"] == "Key"

def test_public_snapshot_defaults_to_everything(self, tmp_path: Path) -> None:
self._write_locale(tmp_path / "p", "en", {"title": "Products"})
reg = I18nRegistry(default_locale="en", supported_locales=["en"])
reg.add_source("products", tmp_path / "p")
reg.load()
assert reg.messages_snapshot("en", include_admin=False) == reg.messages_snapshot("en")

def test_missing_locale_file_is_warning_not_error(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
Expand Down
25 changes: 20 additions & 5 deletions framework/hosting/simple_module_hosting/_inertia_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,25 @@
logger = logging.getLogger(__name__)

_I18N_SESSION_LOCALE_KEY = "__i18n_locale"
_I18N_SESSION_AUDIENCE_KEY = "__i18n_audience"
_INERTIA_HEADER = "x-inertia"
_INERTIA_HEADER_TRUE = "true"


def build_i18n_block(scope: Scope, request: Request) -> dict:
def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = True) -> dict:
"""Assemble the ``i18n`` shared-props block for the current request.

Rules:

* No registry / no locale → serve an empty English block and log once.
* Inertia XHR partials (``X-Inertia: true``) reuse the client-side
cached messages; send ``messages: None`` unless the locale differs
from what was last served on this session.
cached messages; send ``messages: None`` unless the locale — or the
audience, see below — differs from what was last served this session.
* Full page loads and locale transitions ship the complete dict.
* Anonymous visitors receive the public snapshot: catalogs of modules
declaring ``i18n_audience="admin"`` are withheld (issue #248). A login
or logout mid-session counts as a change so the freshly-authenticated
client isn't left holding the anonymous catalog (or vice versa).
"""
# Test fixtures sometimes build a bare FastAPI with a partial app.state.sm
# stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable.
Expand All @@ -42,6 +47,7 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
return {"locale": "en", "supportedLocales": ["en"], "messages": {}}

is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE
audience = "full" if is_authenticated else "public"
session_dict = scope.get("session")
# When the session is absent (pre-session-middleware routes, WebSocket
# upgrades), treat locale as "unchanged" so Inertia XHR requests still
Expand All @@ -52,13 +58,22 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
locale_changed = last_locale != locale
if locale_changed:
session_dict[_I18N_SESSION_LOCALE_KEY] = locale
last_audience = session_dict.get(_I18N_SESSION_AUDIENCE_KEY)
audience_changed = last_audience != audience
if audience_changed:
session_dict[_I18N_SESSION_AUDIENCE_KEY] = audience
else:
locale_changed = False
send_messages = (not is_inertia) or locale_changed
audience_changed = False
send_messages = (not is_inertia) or locale_changed or audience_changed
return {
"locale": locale,
"supportedLocales": registry.available_locales(),
"messages": registry.messages_snapshot(locale) if send_messages else None,
"messages": (
registry.messages_snapshot(locale, include_admin=is_authenticated)
if send_messages
else None
),
}


Expand Down
3 changes: 2 additions & 1 deletion framework/hosting/simple_module_hosting/i18n_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ def build_i18n_registry(
extra_sources: list[tuple[str, str, Path]] = []

for mod in modules:
audience = getattr(mod.meta, "i18n_audience", "public")
for namespace, locale_dir in mod.locale_dirs().items():
registry.add_source(namespace, locale_dir)
registry.add_source(namespace, locale_dir, audience=audience)

host_locales = project_root / "host" / "locales"
if host_locales.is_dir():
Expand Down
2 changes: 1 addition & 1 deletion framework/hosting/simple_module_hosting/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
all_perms = self.permission_registry.all_permissions
frontend_permissions = expand_permissions(resolved, all_perms) if is_authenticated else []

i18n_block = build_i18n_block(scope, request)
i18n_block = build_i18n_block(scope, request, is_authenticated=is_authenticated)

principal_serializer: PrincipalSerializer | None = getattr(
scope["app"].state, "principal_serializer", None
Expand Down
83 changes: 83 additions & 0 deletions framework/hosting/tests/test_inertia_i18n_shared_props.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,89 @@ def test_inertia_shared_props_reflect_cookie_locale() -> None:
assert body["i18n"]["messages"] == {"hello": "Hola"}


def _build_audience_app(tmp_path) -> FastAPI:
"""App with a *loaded* registry (public + admin sources) and header-driven auth.

``X-Test-Auth: 1`` marks the request authenticated, so one client session
can flip auth state mid-session — the login/logout transition the i18n
block must react to.
"""
import json
from types import SimpleNamespace

for ns, data in (
("pages", {"title": "Pages"}),
("settings", {"key": "Key"}),
):
(tmp_path / ns).mkdir(exist_ok=True)
(tmp_path / ns / "en.json").write_text(json.dumps(data))
reg = I18nRegistry(default_locale="en", supported_locales=["en"])
reg.add_source("pages", tmp_path / "pages")
reg.add_source("settings", tmp_path / "settings", audience="admin")
reg.load()

app = FastAPI()
app.state.sm = SimpleNamespace(i18n_registry=reg)

@app.get("/shared")
def shared(request: Request) -> JSONResponse:
return JSONResponse(request.state.inertia_shared)

app.add_middleware(
InertiaLayoutDataMiddleware,
menu_registry=MenuRegistry(),
permission_registry=PermissionRegistry(),
)
app.add_middleware(
LocaleMiddleware,
supported_locales=["en"],
default_locale="en",
)

class _HeaderAuth:
def __init__(self, app_):
self.app = app_

async def __call__(self, scope, receive, send):
if scope["type"] == "http":
request = Request(scope)
if request.headers.get("X-Test-Auth") == "1":
request.state.user = SimpleNamespace(roles=[])
await self.app(scope, receive, send)

app.add_middleware(_HeaderAuth)
app.add_middleware(SessionMiddleware, secret_key="test-secret")
return app


def test_anonymous_visitors_receive_only_public_catalogs(tmp_path) -> None:
client = TestClient(_build_audience_app(tmp_path))
body = client.get("/shared").json()
assert body["i18n"]["messages"] == {"pages.title": "Pages"}


def test_authenticated_users_receive_admin_catalogs_too(tmp_path) -> None:
client = TestClient(_build_audience_app(tmp_path))
body = client.get("/shared", headers={"X-Test-Auth": "1"}).json()
assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"}


def test_login_mid_session_reships_messages_on_an_inertia_partial(tmp_path) -> None:
"""An Inertia partial normally skips messages — but not right after login,
or the freshly-authenticated client would keep the anonymous catalog."""
client = TestClient(_build_audience_app(tmp_path))
client.get("/shared") # anonymous full load seeds the session audience
body = client.get("/shared", headers={"X-Test-Auth": "1", "X-Inertia": "true"}).json()
assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"}


def test_inertia_partial_with_unchanged_audience_still_skips_messages(tmp_path) -> None:
client = TestClient(_build_audience_app(tmp_path))
client.get("/shared")
body = client.get("/shared", headers={"X-Inertia": "true"}).json()
assert body["i18n"]["messages"] is None


def test_inertia_shared_props_fallback_when_registry_missing(
caplog,
) -> None:
Expand Down
1 change: 1 addition & 0 deletions modules/audit_log/audit_log/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class AuditLogModule(ModuleBase):
route_prefix=API_PREFIX,
view_prefix=VIEW_PREFIX,
depends_on=[_MODULE_USERS],
i18n_audience="admin",
)

def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
Expand Down
1 change: 1 addition & 0 deletions modules/background_tasks/background_tasks/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class BackgroundTasksModule(ModuleBase):
route_prefix=API_PREFIX,
view_prefix=VIEW_PREFIX,
depends_on=[_MODULE_USERS],
i18n_audience="admin",
)

def register_settings(self, app: FastAPI) -> None:
Expand Down
3 changes: 3 additions & 0 deletions modules/branding/branding/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class BrandingModule(ModuleBase):
route_prefix=constants.ROUTE_PREFIX,
view_prefix=constants.VIEW_PREFIX,
depends_on=[constants._MODULE_SETTINGS, constants._MODULE_FILE_STORAGE],
# Branding's public contribution (site name, colors, design pack) rides
# the shared-props provider, not i18n keys — the catalog is admin forms.
i18n_audience="admin",
)

def register_settings(self, app: FastAPI) -> None:
Expand Down
1 change: 1 addition & 0 deletions modules/dashboard/dashboard/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class DashboardModule(ModuleBase):
route_prefix="/api/dashboard",
view_prefix="/dashboard",
depends_on=[_MODULE_USERS],
i18n_audience="admin",
)

def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
Expand Down
1 change: 1 addition & 0 deletions modules/feature_flags/feature_flags/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class FeatureFlagsModule(ModuleBase):
name="FeatureFlags",
route_prefix="/api/feature_flags",
view_prefix="/feature_flags",
i18n_audience="admin",
)

def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
Expand Down
Loading
Loading