Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
# SM_USERS_BOOTSTRAP_USER_EMAIL=user@example.com
# SM_USERS_BOOTSTRAP_USER_PASSWORD=changeme

# Locales this install ships (optional). `make doctor` checks the locale files
# on disk against one another either way — SM014/SM015 catch a translation that
# has drifted from the default catalog with no configuration at all. Declaring
# the set here additionally turns on SM013, which reports a module that never
# shipped one of the named locales. Like every SM_ variable it takes precedence
# over the value edited at /admin/settings, so leave it commented unless you
# mean to pin the list for this process.
# SM_I18N_SUPPORTED_LOCALES=["en","es"]
# SM_I18N_DEFAULT_LOCALE=en

# ── Process identity ────────────────────────────────────────────────────
# These describe how this process was launched rather than how the app is
# configured, so they stay in the environment.
Expand Down
31 changes: 31 additions & 0 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,35 @@ Do NOT declare `const schema = z.object({ ... t('...') })` at module scope — i
- Shared UI strings (`packages/ui/`) live in `packages/ui/locales/`, namespaced `ui.*`.
- Both are auto-discovered at boot alongside module contributions.

### Strings the server composes

Neither lint check can see a sentence built in Python. `scripts/check_untranslated_strings.mjs` parses `.tsx`; an f-string in a service reaches the page already rendered, and no catalog can translate it afterwards.

So a payload field a page renders verbatim must carry *translated* text, not assembled text. Take the request's `Translator` (`TranslatorDep` in an endpoint, or pass it down) and emit a key plus its interpolation arguments:

```python
# no
summary = f"Changed {', '.join(fields)} on {label}"
# yes
summary = t.t("users.recent_activity.summary.changed_fields", fields=", ".join(fields), label=label)
```

This is the same shape as menus (`MenuItem.label_key` resolved in `MenuRegistry.get_for_user(translate=…)`) and audit rows (`AuditLink.label_key`): a registration states a key, the request resolves it, and an unresolved key falls back to the English literal rather than putting a dotted key on screen.

### One key, one placeholder

A value that needs its own markup mid-sentence — a permission name in `<code>`, a count in `<b>` — still belongs to *one* catalog key. Splicing `…_prefix` + value + `…_suffix` hands a translator two fragments and no way to move the value, which is the first thing a different word order needs to do.

`InterpolatedText` (`@simple-module-py/ui/components/InterpolatedText`) renders the whole sentence from one key and puts the markup at the placeholder:

```tsx
<InterpolatedText render={(slot) => t(keys.host.error.forbidden_permission, { permission: slot })}>
<code>{requiredPermission}</code>
</InterpolatedText>
```

The catalog entry is an ordinary `"Your role doesn't include {permission}. Ask an admin."`. A translation that drops the placeholder still shows the value (appended); one that repeats it keeps every word of the copy.

### Supported locales

Configure via env:
Expand All @@ -628,3 +657,5 @@ The active locale lands on `request.state.locale`. The `<LocaleSwitcher />` comp
### Diagnostics

App boot runs `I18nDiagnostics` against every module's declared locale dirs. See codes `SM013`–`SM016` in the table above. Warnings are printed in dev; errors fail the boot in production.

`make doctor` runs the same checks, and it runs them whether or not `SM_I18N_SUPPORTED_LOCALES` is set. With the variable unset each namespace is checked against the locale files it actually ships: SM014/SM015 still hold every translation to the default catalog's key set, while SM013 stays quiet, since a locale nothing promised cannot be missing. Declaring the set turns SM013 back on — that is the check that reports a module which never shipped one of the locales the install claims to support.
28 changes: 19 additions & 9 deletions framework/core/simple_module_core/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@

Exits with status 1 if any ERROR-level diagnostics are reported.

i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
(or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
are picked up relative to the project root (``SM_PROJECT_ROOT``, else the
directory of the discovered ``.env``, else the current working dir).
i18n checks always run. ``SM_I18N_SUPPORTED_LOCALES`` (env or ``.env``)
declares the locale set this install promises, which also enables SM013 for a
namespace that never shipped one; with it unset, each namespace is checked
against the locale files it actually has, so key drift between two shipped
translations is still caught. Host-level ``host/locales/`` and shared
``packages/ui/locales/`` are picked up relative to the project root
(``SM_PROJECT_ROOT``, else the directory of the discovered ``.env``, else the
current working dir).
"""

from __future__ import annotations
Expand All @@ -36,8 +40,14 @@
from simple_module_core.exceptions import InvalidModuleError


def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
"""Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset.
def _load_i18n_settings_from_env() -> tuple[list[str] | None, str]:
"""Return ``(supported_locales, default_locale)``.

``supported_locales`` is ``None`` when ``SM_I18N_SUPPORTED_LOCALES`` is
unset or unparseable — the diagnostic then falls back to the locale files
each namespace ships rather than running no locale checks at all. The
default locale always has a value, because a parity comparison needs to
know which side is the source of truth.

Reads env vars directly to avoid a dependency on ``simple_module_hosting``.
Honors ``.env`` by merging it into ``os.environ`` if present (pydantic-
Expand All @@ -46,9 +56,10 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
for key, value in parse_dotenv().items():
os.environ.setdefault(key, value)

default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES")
if not supported_raw:
return None, None
return None, default

try:
supported = json.loads(supported_raw)
Expand All @@ -57,9 +68,8 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
supported = [s.strip() for s in supported_raw.split(",") if s.strip()]

if not isinstance(supported, list) or not supported:
return None, None
return None, default

default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
return supported, default


Expand Down
30 changes: 27 additions & 3 deletions framework/core/simple_module_core/diagnostics/_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,28 @@ class I18nDiagnostics:

def __init__(
self,
supported_locales: list[str],
supported_locales: list[str] | None,
default_locale: str,
extra_sources: list[tuple[str, str, Path]] | None = None,
) -> None:
"""Build the diagnostic.

``supported_locales`` is the set of locales this install promises to
ship. ``None`` means *nobody declared one* — each namespace is then
checked against the locale files it actually has on disk. SM013 is
skipped in that mode (a locale nothing promised cannot be missing),
but SM014/SM015 still hold every shipped translation to the default
locale's key set. Without this, an install that never set
``SM_I18N_SUPPORTED_LOCALES`` ran no locale checks at all and drift
accumulated with nothing to flag it.

``extra_sources`` is an optional list of ``(reporter_name, namespace,
locale_dir)`` triples for locale directories that aren't owned by any
``ModuleBase`` instance — notably the host's ``host/locales/`` and
the shared ``packages/ui/locales/``. ``reporter_name`` is used as the
``module_name`` field on findings for display purposes.
"""
self.supported_locales = list(supported_locales)
self.supported_locales = None if supported_locales is None else list(supported_locales)
self.default_locale = default_locale
self.extra_sources = list(extra_sources or [])

Expand All @@ -55,10 +64,14 @@ def _check_namespace(
) -> list[Diagnostic]:
findings: list[Diagnostic] = []
per_locale_keys: dict[str, set[str]] = {}
declared = self.supported_locales is not None
locales = self.supported_locales if declared else self._locales_on_disk(locale_dir)

for locale in self.supported_locales:
for locale in locales or ():
path = locale_dir / f"{locale}.json"
if not path.is_file():
if not declared:
continue
findings.append(
Diagnostic(
level=DiagnosticLevel.WARNING,
Expand Down Expand Up @@ -119,3 +132,14 @@ def _check_namespace(
)
)
return findings

@staticmethod
def _locales_on_disk(locale_dir: Path) -> list[str]:
"""Locale tags a directory actually ships, from its ``<tag>.json`` files.

The default locale sorts first only by accident of the alphabet, which
does not matter: the parity comparison below looks it up by name.
"""
if not locale_dir.is_dir():
return []
return sorted(path.stem for path in locale_dir.glob("*.json"))
12 changes: 7 additions & 5 deletions framework/core/simple_module_core/diagnostics/_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,20 @@ def run_diagnostics(
"""Convenience function to run all diagnostics.

When ``migration_state`` is provided, also runs migration diagnostics.
When ``i18n_supported_locales`` and ``i18n_default_locale`` are provided,
also runs i18n locale coverage diagnostics. ``i18n_extra_sources`` lets
callers include host/ui locale dirs that aren't owned by a ``ModuleBase``.
``i18n_default_locale`` turns on the locale checks; ``i18n_supported_locales``
is the declared locale set, and leaving it empty runs those checks against
whatever locale files are on disk instead of skipping them entirely (see
:class:`I18nDiagnostics`). ``i18n_extra_sources`` lets callers include
host/ui locale dirs that aren't owned by a ``ModuleBase``.
"""
diagnostics = ModuleDiagnostics().run(modules)

if i18n_supported_locales and i18n_default_locale:
if i18n_default_locale:
from simple_module_core.diagnostics._i18n import I18nDiagnostics

diagnostics.extend(
I18nDiagnostics(
supported_locales=i18n_supported_locales,
supported_locales=i18n_supported_locales or None,
default_locale=i18n_default_locale,
extra_sources=i18n_extra_sources,
).run(modules)
Expand Down
40 changes: 40 additions & 0 deletions framework/core/tests/test_i18n_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,43 @@ def test_extra_sources_detect_key_drift(tmp_path: Path) -> None:
).run([])
codes = {f.code for f in findings}
assert "SM014" in codes


class TestUndeclaredLocaleSet:
"""``supported_locales=None`` — nobody said which locales this install ships.

``make doctor`` runs with no ``SM_I18N_SUPPORTED_LOCALES`` on a stock
checkout, and the whole diagnostic used to be skipped in that state: drift
between two catalogs that *are* both shipped went unreported because no
third file declared them. The files on disk are themselves the declaration.
"""

def test_key_drift_is_reported_without_a_declared_locale_set(self, tmp_path: Path) -> None:
_write(tmp_path / "p", "en", {"a": "1"})
_write(tmp_path / "p", "es", {"a": "1", "orphan": "x"})
mod = _FakeModule("P", {"p": tmp_path / "p"})
findings = I18nDiagnostics(supported_locales=None, default_locale="en").run([mod])
assert {f.code for f in findings} == {"SM015"}

def test_a_locale_nobody_promised_is_not_reported_missing(self, tmp_path: Path) -> None:
"""SM013 is a broken promise; with no declared set there is no promise."""
_write(tmp_path / "p", "en", {"a": "1"})
_write(tmp_path / "q", "en", {"a": "1"})
_write(tmp_path / "q", "es", {"a": "1"})
mod = _FakeModule("P", {"p": tmp_path / "p", "q": tmp_path / "q"})
findings = I18nDiagnostics(supported_locales=None, default_locale="en").run([mod])
assert findings == []

def test_extra_sources_are_auto_detected_too(self, tmp_path: Path) -> None:
_write(tmp_path / "ui_locales", "en", {"a": "1", "b": "2"})
_write(tmp_path / "ui_locales", "es", {"a": "1"})
findings = I18nDiagnostics(
supported_locales=None,
default_locale="en",
extra_sources=[("packages/ui", "ui", tmp_path / "ui_locales")],
).run([])
assert [f.code for f in findings] == ["SM014"]

def test_a_missing_locale_dir_is_silently_skipped(self, tmp_path: Path) -> None:
mod = _FakeModule("P", {"p": tmp_path / "nope"})
assert I18nDiagnostics(supported_locales=None, default_locale="en").run([mod]) == []
9 changes: 5 additions & 4 deletions host/client_app/pages/Error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Head, Link, router, usePage } from '@inertiajs/react';
import { keys, useT } from '@simple-module-py/i18n';
import { CopyableId } from '@simple-module-py/ui/components/CopyableId';
import { ErrorScreen } from '@simple-module-py/ui/components/ErrorScreen';
import { InterpolatedText } from '@simple-module-py/ui/components/InterpolatedText';
import { Button } from '@simple-module-py/ui/components/ui/button';
import type { SharedProps } from '@simple-module-py/ui/types';
import type { ReactNode } from 'react';
Expand Down Expand Up @@ -125,13 +126,13 @@ function ErrorPage({
let description: ReactNode = message || copy.description;
if (required_permission) {
description = (
<>
{t(keys.host.error.forbidden_permission_prefix)}{' '}
<InterpolatedText
render={(slot) => t(keys.host.error.forbidden_permission, { permission: slot })}
>
<code className="rounded bg-secondary px-1 py-0.5 font-mono text-[12.5px] text-foreground">
{required_permission}
</code>
{t(keys.host.error.forbidden_permission_suffix)}
</>
</InterpolatedText>
);
}

Expand Down
3 changes: 1 addition & 2 deletions host/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
"error": {
"correlation_id_copy": "Copy reference ID",
"forbidden_description": "Your role doesn't include the permission this page needs. Ask an admin to grant it.",
"forbidden_permission_prefix": "Your role doesn't include",
"forbidden_permission_suffix": ". Ask an admin to grant it.",
"forbidden_permission": "Your role doesn't include {permission}. Ask an admin to grant it.",
"forbidden_title": "No access",
"generic_description": "An unexpected error occurred.",
"generic_title": "Error",
Expand Down
53 changes: 26 additions & 27 deletions host/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
"error": {
"correlation_id_copy": "Copiar ID de referencia",
"forbidden_description": "Tu rol no incluye el permiso que necesita esta página. Pide a un administrador que te lo conceda.",
"forbidden_permission_prefix": "Tu rol no incluye",
"forbidden_permission_suffix": ". Pide a un administrador que te lo conceda.",
"forbidden_permission": "Tu rol no incluye {permission}. Pide a un administrador que te lo conceda.",
"forbidden_title": "Sin acceso",
"generic_description": "Ocurrió un error inesperado.",
"generic_title": "Error",
Expand Down Expand Up @@ -82,40 +81,40 @@
"restored": "Conexión restablecida"
},
"setup": {
"title": "Set up your install",
"subtitle": "A few things before this application is ready to use.",
"title": "Configura tu instalación",
"subtitle": "Unas cuantas cosas antes de que esta aplicación esté lista para usarse.",
"connections": {
"heading": "Connections",
"description": "Checking the services this install depends on.",
"retest": "Test again",
"testing": "Testing…"
"heading": "Conexiones",
"description": "Comprobando los servicios de los que depende esta instalación.",
"retest": "Probar de nuevo",
"testing": "Probando…"
},
"migrations": {
"heading": "Database schema",
"behind": "The database is behind the version this code expects.",
"current": "The database schema is up to date.",
"apply": "Apply migrations",
"applying": "Applying…"
"heading": "Esquema de la base de datos",
"behind": "La base de datos está por detrás de la versión que espera este código.",
"current": "El esquema de la base de datos está actualizado.",
"apply": "Aplicar migraciones",
"applying": "Aplicando…"
},
"administrator": {
"heading": "Create an administrator",
"description": "This account can sign in and manage the install.",
"email": "Email",
"password": "Password",
"full_name": "Full name",
"submit": "Create administrator",
"submitting": "Creating…",
"created": "Administrator created. Reloading…"
"heading": "Crea un administrador",
"description": "Esta cuenta puede iniciar sesión y administrar la instalación.",
"email": "Correo electrónico",
"password": "Contraseña",
"full_name": "Nombre completo",
"submit": "Crear administrador",
"submitting": "Creando…",
"created": "Administrador creado. Recargando…"
},
"steps": {
"heading": "Setup steps",
"complete": "Done",
"pending": "Pending",
"heading": "Pasos de configuración",
"complete": "Hecho",
"pending": "Pendiente",
"migrations": {
"title": "Apply database migrations",
"description": "Bring the database schema up to the version this code expects."
"title": "Aplicar las migraciones de la base de datos",
"description": "Actualiza el esquema de la base de datos a la versión que espera este código."
}
},
"error": "Something went wrong. See the detail above."
"error": "Algo ha salido mal. Consulta el detalle de arriba."
}
}
Loading
Loading