diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5b389c45..b8a6f00d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -59,7 +59,19 @@ jobs: enable-cache: true cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} - run: make install-py - - run: make test + - run: make test-py + + js-tests: + name: JS tests (Vitest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + - run: make install-js + - run: make test-js js-lint: name: JS lint & format @@ -116,6 +128,7 @@ jobs: - python-tests - js-lint - js-typecheck + - js-tests - file-size-check if: always() steps: diff --git a/Makefile b/Makefile index dabbb045..ee147cdf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-e2e lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size # Install install: @@ -37,9 +37,14 @@ build: npm run build # Testing -test: +test: test-py test-js + +test-py: uv run pytest +test-js: + npm test + test-e2e: ## Run end-to-end browser smoke tests (requires `make docker-up` + `make dev` and `uv run playwright install chromium`) uv run pytest -m e2e tests/e2e @@ -58,6 +63,11 @@ ci-js-lint: ci-js-typecheck: npx tsc --noEmit -p host/client_app/tsconfig.json + @for cfg in modules/*/tsconfig.json packages/*/tsconfig.json; do \ + [ -f "$$cfg" ] || continue; \ + echo "tsc -p $$cfg"; \ + npx tsc --noEmit -p "$$cfg" || exit 1; \ + done # Enforce a max of 300 lines per .py/.ts/.tsx file. # Exempts vendored shadcn components under packages/ui/src/components/ui/**. diff --git a/README.md b/README.md index 7d19edf5..03baa523 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,9 @@ See `framework-conventions.md` for the settings-per-module convention. - **Modules**: discovered via Python entry points at boot. Each module subclasses `ModuleBase` and opts into the lifecycle hooks it needs (`register_routes`, `register_menu_items`, `register_permissions`, `register_middleware`, `on_startup`, ...). - **Database isolation**: PostgreSQL → one schema per module. SQLite → single schema, `__tablename__` prefixed with the module name. -- **Middleware pipeline** (LIFO order of execution): CorrelationId → RequestLogging → SecurityHeaders → Session → `` → Tenant (opt-in) → InertiaLayoutData → app. -- **Diagnostics**: `make doctor` runs a static analyzer over installed modules looking for orphan pages, phantom renders, empty modules, framework/plugin coupling, and migration drift. Errors fail the boot in production. +- **Middleware pipeline** (LIFO order of execution): CorrelationId → RequestLogging → SecurityHeaders → Session → `` → Tenant (opt-in) → Locale → InertiaLayoutData → app. +- **Diagnostics**: `make doctor` runs a static analyzer over installed modules looking for orphan pages, phantom renders, empty modules, framework/plugin coupling, migration drift, and locale-file consistency. Errors fail the boot in production. +- **Internationalization**: per-module `locales/.json` files merged at boot into `I18nRegistry`. Frontend uses `i18next` with type-safe keys; backend uses `Babel` for CLDR plurals. Locale resolved per request via cookie → `Accept-Language` → `SM_I18N_DEFAULT_LOCALE`. See `docs/framework-conventions.md` → Internationalization. Deeper dives in `docs/plans/`: diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 62f6a781..2cfb9cf9 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -213,5 +213,110 @@ Dispatch walks the event's MRO, so subscribing to a base class delivers subclass | SM010 | ERROR | DB revision behind migration head | | SM011 | WARNING | Module table not in migration history | | SM012 | WARNING | `register_settings` overridden but nothing added to `app.state` | +| SM013 | WARNING | Locale file missing for a supported locale | +| SM014 | WARNING | Non-default locale missing keys present in the default | +| SM015 | WARNING | Non-default locale has keys not in the default | +| SM016 | ERROR | Locale JSON invalid or contains non-string leaves | Run diagnostics manually: `make doctor`. + +## Internationalization + +Modules ship translations as JSON under `/locales/.json` and declare them via `ModuleBase.locale_dirs()`: + +```python +import importlib.resources +from pathlib import Path + +class OrdersModule(ModuleBase): + def locale_dirs(self) -> dict[str, Path]: + return {"orders": Path(str(importlib.resources.files(__package__) / "locales"))} +``` + +`make new-module` scaffolds this method and a matching `locales/en.json` automatically. + +### Key naming + +Keys are namespaced by the module and hierarchical by area. Convention: `..` — e.g. `orders.browse.title`. Use `snake_case` for leaves. Nested JSON objects are flattened at boot — `{"browse": {"title": "X"}}` under namespace `orders` becomes `orders.browse.title` at runtime. + +### Interpolation + +Placeholders use `{name}` syntax, consistent between frontend and backend: + +```json +{ "greeting": "Hello, {name}" } +``` + +```tsx +t('orders.greeting', { name: user.name }) // frontend +``` + +```python +t.t("orders.greeting", name=user.name) # backend +``` + +Missing placeholders are left verbatim (`"Hello, {name}"`) rather than raising. + +### Pluralization + +Suffix keys with CLDR categories (`_zero`, `_one`, `_two`, `_few`, `_many`, `_other`); only `_other` is required. Pass `count` as a param: + +```json +{ + "items_one": "{count} item", + "items_other": "{count} items" +} +``` + +```tsx +t('orders.items', { count: items.length }) +``` + +Backend uses Babel's CLDR plural rules; frontend uses i18next's `Intl.PluralRules`. Both follow the same CLDR categories, so behavior matches across the stack. + +### Validation messages + +Zod schemas with translated messages must be constructed **inside** a hook so they resolve against the active locale: + +```ts +export function useProductSchema() { + const { t } = useT(); + return z.object({ + name: z.string().min(1, t('products.validation.name_required')), + }); +} +``` + +Do NOT declare `const schema = z.object({ ... t('...') })` at module scope — it will resolve against whatever locale was active at first render, forever. + +### Host and shared-package strings + +- Host strings (landing page, error page) live in `host/locales/` and are namespaced `host.*`. +- Shared UI strings (`packages/ui/`) live in `packages/ui/locales/`, namespaced `ui.*`. +- Both are auto-discovered at boot alongside module contributions. + +### Supported locales + +Configure via env: + +``` +SM_I18N_DEFAULT_LOCALE=en +SM_I18N_SUPPORTED_LOCALES=en,es,de +SM_I18N_COOKIE_NAME=locale +``` + +The default locale must be in the supported list (enforced by a pydantic validator). + +### Locale resolution order + +Per request, `LocaleMiddleware` picks the active locale in this order: + +1. Cookie named by `SM_I18N_COOKIE_NAME` (default `locale`), validated against `SM_I18N_SUPPORTED_LOCALES`. +2. `Accept-Language` header, with q-value parsing and longest-prefix match (`es-MX` → `es`). +3. `SM_I18N_DEFAULT_LOCALE`. + +The active locale lands on `request.state.locale`. The `` component POSTs to `/i18n/set-locale`, which sets a 1-year cookie and redirects back. + +### Diagnostics + +`make doctor` (and app boot) run `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. diff --git a/docs/superpowers/plans/2026-04-15-i18n-localization.md b/docs/superpowers/plans/2026-04-15-i18n-localization.md new file mode 100644 index 00000000..7ff5ab5b --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-i18n-localization.md @@ -0,0 +1,3489 @@ +# i18n Localization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add unified frontend + backend localization, driven by per-module JSON files, with type-safe key access on the frontend and CLDR-correct plurals on the backend. + +**Architecture:** Module authors contribute `locales/.json` files alongside their Python package. At boot, the host merges them into an `I18nRegistry` (backend) and emits a `generated-resources.ts` file (frontend typing). A cookie-based `LocaleMiddleware` resolves the active locale per request and ships the active-locale messages as Inertia shared props. Frontend uses `i18next` + `react-i18next` with TypeScript module augmentation for compile-time key checking. Backend exposes a `TranslatorDep` for endpoint use, with plural resolution via `babel.plural.PluralRule`. + +**Tech Stack:** Python 3.12, FastAPI, `babel>=2.14`, `i18next`, `react-i18next`, Vite, Vitest, TypeScript 5.7. + +**Spec reference:** [docs/superpowers/specs/2026-04-15-i18n-localization-design.md](../specs/2026-04-15-i18n-localization-design.md) + +--- + +## File Structure + +### New files + +| File | Responsibility | +|---|---| +| `framework/core/simple_module_core/i18n.py` | `I18nRegistry`, `Translator`, JSON flattening, plural resolution | +| `framework/core/tests/test_i18n.py` | Unit tests for registry + translator + plurals | +| `framework/core/simple_module_core/diagnostics/_i18n.py` | `I18nDiagnostics` class | +| `framework/hosting/simple_module_hosting/i18n_middleware.py` | `LocaleMiddleware` (cookie → Accept-Language → default) | +| `framework/hosting/simple_module_hosting/i18n_deps.py` | `TranslatorDep` FastAPI dependency | +| `framework/hosting/simple_module_hosting/i18n_manifest.py` | Emit `generated-resources.ts` for frontend type inference | +| `framework/hosting/tests/test_locale_middleware.py` | Middleware unit tests | +| `framework/hosting/tests/test_translator_dep.py` | Dependency integration test | +| `framework/hosting/tests/test_i18n_manifest.py` | Test `generated-resources.ts` emission | +| `host/routes_i18n.py` | `POST /i18n/set-locale` switcher endpoint | +| `host/locales/en.json` | Host-level strings (landing, error page, switcher labels) | +| `host/locales/es.json` | Spanish translations for the above | +| `host/client_app/i18n.ts` | Initial `configureI18n` + `router.on('success')` updater | +| `host/client_app/i18n-types.ts` | i18next module augmentation | +| `host/client_app/generated-resources.ts` | GENERATED — default-locale key shape | +| `packages/ui/locales/en.json` | Shared UI strings (`ui.*`) | +| `packages/ui/locales/es.json` | Spanish translations | +| `packages/ui/src/components/LocaleSwitcher.tsx` | Dropdown switcher component | +| `packages/ui/src/components/LocaleSwitcher.test.tsx` | Vitest unit test | +| `packages/i18n/package.json` | Workspace package descriptor | +| `packages/i18n/tsconfig.json` | TS config | +| `packages/i18n/src/index.ts` | `configureI18n`, `updateI18n`, `useT`, `t` re-exports | +| `packages/i18n/src/configure.test.ts` | Vitest unit tests | +| `modules///locales/en.json` | Each of `auth`, `dashboard`, `products` — extracted strings | +| `modules///locales/es.json` | Spanish counterparts | +| `vitest.config.ts` (root) | Vitest project config | +| `vitest.setup.ts` (root) | Vitest setup (jest-dom matchers) | + +### Modified files + +| File | Change | +|---|---| +| `framework/core/simple_module_core/module.py` | Add `locale_dirs()` method to `ModuleBase` | +| `framework/core/simple_module_core/__init__.py` | Export `I18nRegistry`, `Translator` | +| `framework/core/simple_module_core/diagnostics/__init__.py` | Export `I18nDiagnostics` | +| `framework/core/simple_module_core/diagnostics/_runner.py` | Run i18n diagnostics | +| `framework/core/pyproject.toml` | Add `babel>=2.14` dep | +| `framework/hosting/simple_module_hosting/settings.py` | Add `i18n_default_locale`, `i18n_supported_locales`, `i18n_cookie_name` | +| `framework/hosting/simple_module_hosting/app_builder.py` | Wire i18n registry + middleware + manifest emission | +| `framework/hosting/simple_module_hosting/middleware.py` | (No change — new middleware is its own file) | +| `host/main.py` | (No change — goes through `create_app`) | +| `host/routes.py` | (No change — switcher lives in new file `routes_i18n.py`) | +| `host/client_app/main.tsx` | Import `i18n-types.ts` to activate augmentation | +| `host/client_app/app.tsx` | Call `configureI18n` at boot; wire `updateI18n` on navigate | +| `host/client_app/package.json` | Add `i18next`, `react-i18next`, `@simple-module/i18n` deps | +| `packages/ui/package.json` | Add `@simple-module/i18n` dep | +| `packages/ui/src/layouts/AuthenticatedLayout.tsx` | Mount `` | +| `packages/ui/src/layouts/PublicLayout.tsx` | Mount `` | +| `package.json` (root) | Add `vitest`, `@testing-library/*`, `@vitest/ui` devDeps; add `test` script | +| `Makefile` | Add `test-js` target; fold into `make test` | +| `modules/auth/auth/module.py` | Add `locale_dirs()` | +| `modules/auth/auth/endpoints/*.py` | Swap hardcoded strings for `TranslatorDep` | +| `modules/dashboard/dashboard/module.py` | Add `locale_dirs()` | +| `modules/dashboard/dashboard/pages/*.tsx` | Swap strings for `useT()` | +| `modules/products/products/module.py` | Add `locale_dirs()` | +| `modules/products/products/pages/Browse.tsx` | Swap strings for `useT()` | +| `modules/products/products/pages/Create.tsx` | Swap strings for `useT()` | +| `modules/products/products/pages/Edit.tsx` | Swap strings for `useT()` | +| `modules/products/products/pages/validation.ts` | Export `useProductSchema()` hook | +| `host/client_app/pages/Landing.tsx` | Swap strings for `useT()` | +| `host/client_app/pages/Error.tsx` | Swap strings for `useT()` | +| `scripts/new_module.py` | Create `locales/en.json`; update `locale_dirs()` in generated `module.py` | +| `scripts/_templates_py.py` | Update `module_py` template to include `locale_dirs()` | +| `scripts/_templates_tsx.py` | Update Browse/Create/Edit templates to use `useT()` | +| `docs/framework-conventions.md` | Add Internationalization section | +| `README.md` | Add "Internationalization" bullet to Architecture section | + +--- + +## Build Sequence Overview + +- **Tasks 1–6:** Backend core (registry, translator, plurals, middleware, dep) — no UI depends on this yet. +- **Tasks 7–10:** Manifest emission + frontend types + `packages/i18n`. +- **Tasks 11–13:** Switcher endpoint + React wiring + switcher component. +- **Tasks 14–15:** Vitest setup + frontend tests. +- **Task 16:** Diagnostics. +- **Task 17:** Extract strings from `packages/ui`. +- **Tasks 18–20:** Extract strings from each module (auth, dashboard, products) + host. +- **Task 21:** Scaffolder updates. +- **Task 22:** Docs + README. +- **Task 23:** End-to-end smoke verification. + +--- + +## Task 1: `I18nRegistry` — load and flatten JSON files + +**Files:** +- Create: `framework/core/simple_module_core/i18n.py` +- Create: `framework/core/tests/test_i18n.py` +- Modify: `framework/core/pyproject.toml` (add `babel`) + +**Context:** The registry is pure Python data-structure code with no FastAPI/Babel coupling yet. It loads JSON from disk, flattens nested dicts to dotted keys, and stores per-locale maps. Plural resolution comes in Task 3. + +- [ ] **Step 1: Add `babel` dependency to core** + +Edit `framework/core/pyproject.toml` — add `"babel>=2.14"` to the `dependencies` list: + +```toml +dependencies = [ + "babel>=2.14", + "fastapi>=0.115", + "packaging>=23.0", + "pydantic>=2.0", + "pydantic-settings>=2.0", + "pyee>=12.0", +] +``` + +Then run: + +```bash +uv sync --all-packages +``` + +- [ ] **Step 2: Write failing test for JSON flattening** + +Create `framework/core/tests/test_i18n.py`: + +```python +"""Tests for I18nRegistry, Translator, and plural resolution.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from simple_module_core.i18n import I18nRegistry, flatten_messages + + +class TestFlattenMessages: + def test_flattens_nested_dict_with_dotted_keys(self) -> None: + nested = {"browse": {"title": "Products", "count_one": "{count} product"}} + flat = flatten_messages(nested) + assert flat == { + "browse.title": "Products", + "browse.count_one": "{count} product", + } + + def test_flattens_deeply_nested(self) -> None: + nested = {"a": {"b": {"c": "hello"}}} + assert flatten_messages(nested) == {"a.b.c": "hello"} + + def test_rejects_non_string_leaves(self) -> None: + nested = {"count": 42} + with pytest.raises(ValueError, match="must be string"): + flatten_messages(nested) + + def test_rejects_list_values(self) -> None: + nested = {"items": ["a", "b"]} + with pytest.raises(ValueError, match="must be string"): + flatten_messages(nested) + + def test_empty_dict_returns_empty(self) -> None: + assert flatten_messages({}) == {} +``` + +- [ ] **Step 3: Run the test and verify it fails** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py::TestFlattenMessages -v +``` + +Expected: `ModuleNotFoundError: No module named 'simple_module_core.i18n'`. + +- [ ] **Step 4: Implement `flatten_messages`** + +Create `framework/core/simple_module_core/i18n.py`: + +```python +"""Internationalization registry and translator.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def flatten_messages( + nested: dict[str, Any], + *, + prefix: str = "", +) -> dict[str, str]: + """Flatten a nested dict of string leaves to dotted keys. + + {"browse": {"title": "X"}} -> {"browse.title": "X"} + + Raises ValueError if any leaf is not a string. + """ + out: dict[str, str] = {} + for key, value in nested.items(): + composed = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + out.update(flatten_messages(value, prefix=composed)) + elif isinstance(value, str): + out[composed] = value + else: + raise ValueError( + f"Locale value at '{composed}' must be string or nested dict, " + f"got {type(value).__name__}" + ) + return out +``` + +- [ ] **Step 5: Verify flattening tests pass** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py::TestFlattenMessages -v +``` + +Expected: 5 passed. + +- [ ] **Step 6: Write failing test for `I18nRegistry` loading** + +Append to `framework/core/tests/test_i18n.py`: + +```python +class TestI18nRegistry: + def _write_locale(self, dir_: Path, lang: str, data: dict) -> None: + dir_.mkdir(parents=True, exist_ok=True) + (dir_ / f"{lang}.json").write_text(json.dumps(data)) + + def test_loads_single_namespace(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "products", "en", {"browse": {"title": "Products"}}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("products", tmp_path / "products") + reg.load() + assert reg.messages("en") == {"products.browse.title": "Products"} + + def test_merges_multiple_namespaces(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "p", "en", {"title": "Products"}) + self._write_locale(tmp_path / "a", "en", {"title": "Auth"}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("products", tmp_path / "p") + reg.add_source("auth", tmp_path / "a") + reg.load() + assert reg.messages("en") == {"products.title": "Products", "auth.title": "Auth"} + + def test_available_locales_reports_loaded(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "h", "en", {"k": "v"}) + self._write_locale(tmp_path / "h", "es", {"k": "v_es"}) + reg = I18nRegistry(default_locale="en", supported_locales=["en", "es", "de"]) + reg.add_source("host", tmp_path / "h") + reg.load() + assert sorted(reg.available_locales()) == ["en", "es"] + + def test_missing_locale_file_is_warning_not_error( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + self._write_locale(tmp_path / "h", "en", {"k": "v"}) + # No es.json + reg = I18nRegistry(default_locale="en", supported_locales=["en", "es"]) + reg.add_source("host", tmp_path / "h") + with caplog.at_level("WARNING"): + reg.load() + assert "missing locale file" in caplog.text.lower() + assert reg.messages("es") == {} + + def test_invalid_json_raises(self, tmp_path: Path) -> None: + d = tmp_path / "h" + d.mkdir() + (d / "en.json").write_text("{not valid json") + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("host", d) + with pytest.raises(ValueError, match="invalid JSON"): + reg.load() + + def test_messages_unknown_locale_returns_empty(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "h", "en", {"k": "v"}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("host", tmp_path / "h") + reg.load() + assert reg.messages("fr") == {} +``` + +- [ ] **Step 7: Run and verify failure** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py::TestI18nRegistry -v +``` + +Expected: `ImportError` on `I18nRegistry`. + +- [ ] **Step 8: Implement `I18nRegistry`** + +Append to `framework/core/simple_module_core/i18n.py`: + +```python +class I18nRegistry: + """Merged view of all module locale JSON files, keyed by locale. + + Usage:: + + registry = I18nRegistry(default_locale="en", supported_locales=["en", "es"]) + registry.add_source("products", Path("modules/products/products/locales")) + registry.load() + registry.messages("en") # {"products.browse.title": "Products", ...} + """ + + 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._messages: dict[str, dict[str, str]] = {} + + 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 load(self) -> None: + """Read and flatten all registered JSON files. + + Missing .json files for declared supported_locales log a + warning but do not raise. Malformed JSON raises ValueError. + """ + self._messages = {locale: {} for locale in self.supported_locales} + + for namespace, locale_dir in self._sources: + for locale in self.supported_locales: + path = locale_dir / f"{locale}.json" + if not path.is_file(): + logger.warning( + "Missing locale file for namespace '%s': %s", + namespace, + path, + ) + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError(f"{path} must contain a JSON object at the top level") + flat = flatten_messages(raw, prefix=namespace) + self._messages[locale].update(flat) + + def available_locales(self) -> list[str]: + """Locales that have at least one loaded message.""" + return [locale for locale, msgs in self._messages.items() if msgs] + + def messages(self, locale: str) -> dict[str, str]: + """Flat dotted-key map for the given locale. Empty dict if unknown.""" + return dict(self._messages.get(locale, {})) +``` + +- [ ] **Step 9: Verify all tests pass** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py -v +``` + +Expected: 11 passed. + +- [ ] **Step 10: Commit** + +```bash +git add framework/core/simple_module_core/i18n.py \ + framework/core/tests/test_i18n.py \ + framework/core/pyproject.toml +git commit -m "feat(core): add I18nRegistry for per-module locale JSON loading" +``` + +--- + +## Task 2: `Translator` — interpolation + +**Files:** +- Modify: `framework/core/simple_module_core/i18n.py` +- Modify: `framework/core/tests/test_i18n.py` + +**Context:** Simple string interpolation with `{name}` placeholders via `str.format_map` with a default-dict that returns the placeholder if the param is missing. No plurals yet — that comes in Task 3. + +- [ ] **Step 1: Write failing tests for interpolation + fallback** + +Append to `framework/core/tests/test_i18n.py`: + +```python +from simple_module_core.i18n import Translator + + +class TestTranslator: + def _registry_with(self, locale_data: dict[str, dict[str, str]]) -> I18nRegistry: + """Build a registry directly from in-memory data (bypasses filesystem).""" + reg = I18nRegistry(default_locale="en", supported_locales=list(locale_data.keys())) + reg._messages = locale_data # noqa: SLF001 — test-only shortcut + return reg + + def test_returns_string_for_known_key(self) -> None: + reg = self._registry_with({"en": {"hello": "Hello"}}) + t = Translator(reg, locale="en", default_locale="en") + assert t.t("hello") == "Hello" + + def test_interpolates_named_placeholders(self) -> None: + reg = self._registry_with({"en": {"greeting": "Hello, {name}"}}) + t = Translator(reg, locale="en", default_locale="en") + assert t.t("greeting", name="Ana") == "Hello, Ana" + + def test_missing_placeholder_keeps_brace_form(self) -> None: + reg = self._registry_with({"en": {"greeting": "Hello, {name}"}}) + t = Translator(reg, locale="en", default_locale="en") + # Param not supplied — value is the raw placeholder, not an exception. + assert t.t("greeting") == "Hello, {name}" + + def test_falls_back_to_default_locale(self) -> None: + reg = self._registry_with({"en": {"hello": "Hello"}, "es": {}}) + t = Translator(reg, locale="es", default_locale="en") + assert t.t("hello") == "Hello" + + def test_unknown_key_returns_key(self) -> None: + reg = self._registry_with({"en": {}}) + t = Translator(reg, locale="en", default_locale="en") + assert t.t("missing.key") == "missing.key" + + def test_prefers_requested_locale_over_default(self) -> None: + reg = self._registry_with({"en": {"hello": "Hello"}, "es": {"hello": "Hola"}}) + t = Translator(reg, locale="es", default_locale="en") + assert t.t("hello") == "Hola" +``` + +- [ ] **Step 2: Run and verify failure** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py::TestTranslator -v +``` + +Expected: `ImportError: cannot import name 'Translator'`. + +- [ ] **Step 3: Implement `Translator` (interpolation only, no plurals yet)** + +Append to `framework/core/simple_module_core/i18n.py`: + +```python +class _SafeFormatDict(dict): + """Dict that returns ``{key}`` for missing keys so str.format_map doesn't raise.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +class Translator: + """Request-scoped translator bound to a specific locale. + + Construct via:: + + Translator(registry, locale=request.state.locale, default_locale="en") + + Resolution order for :meth:`t`: + + 1. Look up key in ``locale``; if missing, fall back to ``default_locale``. + 2. If still missing, return the key itself (with a debug log). + 3. Interpolate ``{name}``-style placeholders using supplied kwargs. + Missing placeholders are left as ``{name}`` (not raised). + """ + + def __init__( + self, + registry: I18nRegistry, + locale: str, + default_locale: str, + ) -> None: + self._registry = registry + self.locale = locale + self.default_locale = default_locale + + def t(self, key: str, **params: Any) -> str: + """Translate ``key`` with optional interpolation.""" + template = self._lookup(key) + if template is None: + logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale) + return key + return template.format_map(_SafeFormatDict(params)) + + def _lookup(self, key: str) -> str | None: + msgs = self._registry.messages(self.locale) + if key in msgs: + return msgs[key] + if self.locale != self.default_locale: + default = self._registry.messages(self.default_locale) + if key in default: + return default[key] + return None +``` + +- [ ] **Step 4: Run and verify all tests pass** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py -v +``` + +Expected: 17 passed. + +- [ ] **Step 5: Commit** + +```bash +git add framework/core/simple_module_core/i18n.py framework/core/tests/test_i18n.py +git commit -m "feat(core): add Translator with {name} interpolation and locale fallback" +``` + +--- + +## Task 3: Plural resolution via Babel + +**Files:** +- Modify: `framework/core/simple_module_core/i18n.py` +- Modify: `framework/core/tests/test_i18n.py` + +**Context:** When `count` is in params and the key has `_
` variants, resolve the CLDR plural category via `babel.Locale(locale).plural_form` and pick the matching suffixed key. Falls back to the un-suffixed key if no variant exists. + +- [ ] **Step 1: Write failing plural tests** + +Append to `framework/core/tests/test_i18n.py`: + +```python +class TestTranslatorPlurals: + def test_english_one(self) -> None: + reg = Translator.__new__(Translator) # bypass init for brevity + # Proper setup via factory: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = { # noqa: SLF001 + "en": { + "items_one": "{count} item", + "items_other": "{count} items", + } + } + t = Translator(registry, locale="en", default_locale="en") + assert t.t("items", count=1) == "1 item" + + def test_english_other(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = { # noqa: SLF001 + "en": {"items_one": "{count} item", "items_other": "{count} items"} + } + t = Translator(registry, locale="en", default_locale="en") + assert t.t("items", count=5) == "5 items" + + def test_russian_few_many(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en", "ru"]) + registry._messages = { # noqa: SLF001 + "ru": { + "items_one": "{count} предмет", + "items_few": "{count} предмета", + "items_many": "{count} предметов", + "items_other": "{count} предмета", + } + } + t = Translator(registry, locale="ru", default_locale="en") + # Russian: 1 -> one, 2 -> few, 5 -> many + assert t.t("items", count=1) == "1 предмет" + assert t.t("items", count=2) == "2 предмета" + assert t.t("items", count=5) == "5 предметов" + + def test_no_count_no_plural_resolution(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = {"en": {"items": "Items"}} # noqa: SLF001 + t = Translator(registry, locale="en", default_locale="en") + # No 'count' param -> plain lookup. + assert t.t("items") == "Items" + + def test_falls_back_to_other_if_form_missing(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + # Only _other defined; 1 should still resolve via _other. + registry._messages = {"en": {"items_other": "{count} items"}} # noqa: SLF001 + t = Translator(registry, locale="en", default_locale="en") + assert t.t("items", count=1) == "1 items" + + def test_unknown_plural_key_returns_key(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = {"en": {}} # noqa: SLF001 + t = Translator(registry, locale="en", default_locale="en") + assert t.t("missing", count=1) == "missing" +``` + +- [ ] **Step 2: Run and verify failure** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py::TestTranslatorPlurals -v +``` + +Expected: first test fails — `items_one` lookup only tries the base key. + +- [ ] **Step 3: Implement plural resolution** + +Replace the `Translator.t` method in `framework/core/simple_module_core/i18n.py` with: + +```python + def t(self, key: str, **params: Any) -> str: + """Translate ``key`` with optional interpolation and plural resolution. + + When ``count`` is in params, look up ``_`` using + Babel's CLDR plural rule for the active locale, falling back to + ``_other`` and finally ````. + """ + resolved_key = self._resolve_plural_key(key, params) + template = self._lookup(resolved_key) + if template is None and resolved_key != key: + template = self._lookup(key) + if template is None: + logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale) + return key + return template.format_map(_SafeFormatDict(params)) + + def _resolve_plural_key(self, key: str, params: dict[str, Any]) -> str: + count = params.get("count") + if count is None: + return key + form = _plural_form(self.locale, count) + # Prefer the exact form; fall back to _other if that form has no entry. + candidate = f"{key}_{form}" + if self._lookup(candidate) is not None: + return candidate + other = f"{key}_other" + if self._lookup(other) is not None: + return other + return key +``` + +Add the `_plural_form` helper above the class (after the imports): + +```python +from functools import lru_cache + +from babel import Locale + + +@lru_cache(maxsize=64) +def _plural_rule(locale: str): # type: ignore[no-untyped-def] + """Cached CLDR plural rule for a locale tag (e.g. 'en', 'ru', 'pt_BR').""" + return Locale.parse(locale).plural_form + + +def _plural_form(locale: str, count: float) -> str: + """Return CLDR plural category ('one', 'few', 'many', 'other', ...). + + Falls back to 'other' if the locale cannot be parsed by Babel. + """ + try: + rule = _plural_rule(locale) + except Exception: # noqa: BLE001 + return "other" + return rule(count) +``` + +- [ ] **Step 4: Run and verify plural tests pass** + +```bash +cd framework/core && uv run pytest tests/test_i18n.py -v +``` + +Expected: 23 passed. + +- [ ] **Step 5: Commit** + +```bash +git add framework/core/simple_module_core/i18n.py framework/core/tests/test_i18n.py +git commit -m "feat(core): resolve plural forms via Babel CLDR rules" +``` + +--- + +## Task 4: `locale_dirs()` on `ModuleBase` + exports + +**Files:** +- Modify: `framework/core/simple_module_core/module.py` +- Modify: `framework/core/simple_module_core/__init__.py` +- Modify: `framework/core/tests/test_module_base.py` + +**Context:** Give modules a hook to declare their locale directories. Pure metadata method (no registration arg), matches `template_dirs()`. Export `I18nRegistry` and `Translator` from the core package. + +- [ ] **Step 1: Write failing test for the new method** + +Append to `framework/core/tests/test_module_base.py`: + +```python +def test_module_base_locale_dirs_defaults_empty() -> None: + from simple_module_core import ModuleBase, ModuleMeta + + class _M(ModuleBase): + meta = ModuleMeta(name="X") + + assert _M().locale_dirs() == {} + + +def test_module_base_locale_dirs_can_be_overridden(tmp_path) -> None: # type: ignore[no-untyped-def] + from simple_module_core import ModuleBase, ModuleMeta + + class _M(ModuleBase): + meta = ModuleMeta(name="X") + + def locale_dirs(self): # type: ignore[no-untyped-def] + return {"x": tmp_path} + + assert _M().locale_dirs() == {"x": tmp_path} +``` + +- [ ] **Step 2: Run and verify failure** + +```bash +cd framework/core && uv run pytest tests/test_module_base.py -v +``` + +Expected: `AttributeError: '_M' object has no attribute 'locale_dirs'`. + +- [ ] **Step 3: Add `locale_dirs()` method** + +In `framework/core/simple_module_core/module.py`, after the `static_mounts` method (around line 145), insert: + +```python + def locale_dirs(self) -> dict[str, Path]: + """Return ``{namespace: directory}`` mapping for locale JSON files. + + Default returns an empty dict. Override to contribute a module's + locales:: + + return { + "products": importlib.resources.files(__package__) / "locales" + } + + The namespace becomes the key prefix in the merged i18n registry. + A file ``locales/en.json`` containing ``{"browse": {"title": "X"}}`` + becomes the key ``products.browse.title`` at runtime. + + Convention: use the module's lowercase name as the namespace. + """ + return {} +``` + +- [ ] **Step 4: Export `I18nRegistry` and `Translator` from core** + +In `framework/core/simple_module_core/__init__.py`, add the import and extend `__all__`: + +```python +from simple_module_core.i18n import I18nRegistry, Translator +``` + +And in the `__all__` list (alphabetically): + +```python + "I18nRegistry", +``` +(after `"HealthStatus"`) + +```python + "Translator", +``` +(after `"PermissionRegistry"`) + +- [ ] **Step 5: Run tests** + +```bash +cd framework/core && uv run pytest -v +``` + +Expected: all tests pass (existing + 2 new). + +- [ ] **Step 6: Commit** + +```bash +git add framework/core/simple_module_core/module.py \ + framework/core/simple_module_core/__init__.py \ + framework/core/tests/test_module_base.py +git commit -m "feat(core): add ModuleBase.locale_dirs() and export i18n types" +``` + +--- + +## Task 5: i18n settings + `LocaleMiddleware` + +**Files:** +- Modify: `framework/hosting/simple_module_hosting/settings.py` +- Create: `framework/hosting/simple_module_hosting/i18n_middleware.py` +- Create: `framework/hosting/tests/test_locale_middleware.py` + +**Context:** `LocaleMiddleware` resolves the active locale per request from cookie → Accept-Language → default, and sets `request.state.locale`. Runs as an ASGI-style middleware like the existing `TenantMiddleware`. + +- [ ] **Step 1: Add i18n settings fields** + +Edit `framework/hosting/simple_module_hosting/settings.py`. After the `tenant_header` field (line 49), add: + +```python + # Internationalization + i18n_default_locale: str = "en" + """Locale used when no cookie, Accept-Language, or supported locale match.""" + + i18n_supported_locales: list[str] = ["en"] + """Locales the host will serve. Must include i18n_default_locale. + + Set via env as JSON-style list, e.g. ``SM_I18N_SUPPORTED_LOCALES='["en","es"]'``. + """ + + i18n_cookie_name: str = "locale" + """Name of the cookie that overrides browser Accept-Language.""" +``` + +- [ ] **Step 2: Write failing middleware tests** + +Create `framework/hosting/tests/test_locale_middleware.py`: + +```python +"""Tests for LocaleMiddleware request-state population.""" + +from __future__ import annotations + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from simple_module_hosting.i18n_middleware import LocaleMiddleware + + +def _build_app(supported: list[str], default: str, cookie_name: str = "locale") -> Starlette: + async def endpoint(request: Request) -> JSONResponse: + return JSONResponse({"locale": request.state.locale}) + + app = Starlette(routes=[Route("/", endpoint)]) + app.add_middleware( + LocaleMiddleware, + supported_locales=supported, + default_locale=default, + cookie_name=cookie_name, + ) + return app + + +def test_uses_cookie_when_present_and_supported() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", cookies={"locale": "es"}) + assert resp.json() == {"locale": "es"} + + +def test_ignores_cookie_when_locale_not_supported() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", cookies={"locale": "de"}) + # Falls through to Accept-Language, then to default (en). + assert resp.json() == {"locale": "en"} + + +def test_uses_accept_language_when_no_cookie() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", headers={"Accept-Language": "es,en;q=0.8"}) + assert resp.json() == {"locale": "es"} + + +def test_prefix_match_accept_language() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + # "es-MX" should match supported "es" via prefix. + resp = client.get("/", headers={"Accept-Language": "es-MX"}) + assert resp.json() == {"locale": "es"} + + +def test_falls_back_to_default_when_nothing_matches() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", headers={"Accept-Language": "de,fr;q=0.5"}) + assert resp.json() == {"locale": "en"} + + +def test_cookie_takes_precedence_over_accept_language() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get( + "/", + cookies={"locale": "es"}, + headers={"Accept-Language": "de"}, + ) + assert resp.json() == {"locale": "es"} + + +def test_custom_cookie_name() -> None: + app = _build_app(["en", "es"], "en", cookie_name="lang") + client = TestClient(app) + resp = client.get("/", cookies={"lang": "es"}) + assert resp.json() == {"locale": "es"} +``` + +- [ ] **Step 3: Run and verify failure** + +```bash +cd framework/hosting && uv run pytest tests/test_locale_middleware.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'simple_module_hosting.i18n_middleware'`. + +- [ ] **Step 4: Implement `LocaleMiddleware`** + +Create `framework/hosting/simple_module_hosting/i18n_middleware.py`: + +```python +"""LocaleMiddleware — resolve active locale from cookie / Accept-Language / default.""" + +from __future__ import annotations + +from starlette.datastructures import Headers +from starlette.requests import Request +from starlette.types import ASGIApp, Receive, Scope, Send + + +class LocaleMiddleware: + """Set ``request.state.locale`` based on cookie, Accept-Language, and default. + + Resolution order: + + 1. Cookie named ``cookie_name``, validated against ``supported_locales``. + 2. ``Accept-Language`` header, negotiated against supported_locales via + longest-prefix match (``es-MX`` matches supported ``es``). + 3. ``default_locale``. + + Runs as a pure ASGI middleware (no BaseHTTPMiddleware) to match the rest + of the framework's middleware stack. + """ + + def __init__( + self, + app: ASGIApp, + *, + supported_locales: list[str], + default_locale: str, + cookie_name: str = "locale", + ) -> None: + self.app = app + self.supported = list(supported_locales) + self.default_locale = default_locale + self.cookie_name = cookie_name + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope) + locale = self._resolve(request) + request.state.locale = locale + await self.app(scope, receive, send) + + def _resolve(self, request: Request) -> str: + # 1. Cookie. + cookie = request.cookies.get(self.cookie_name) + if cookie and cookie in self.supported: + return cookie + + # 2. Accept-Language. + accept = Headers(scope=request.scope).get("accept-language") + if accept: + matched = self._negotiate(accept) + if matched: + return matched + + # 3. Default. + return self.default_locale + + def _negotiate(self, accept_language: str) -> str | None: + """Parse Accept-Language and return the highest-q supported locale. + + Matches either exact tag or primary prefix (``es-MX`` -> ``es``). + """ + candidates: list[tuple[float, str]] = [] + for part in accept_language.split(","): + part = part.strip() + if not part: + continue + tag, _, q_part = part.partition(";") + tag = tag.strip().lower() + try: + q = float(q_part.split("=", 1)[1]) if q_part.startswith("q=") else 1.0 + except ValueError: + q = 1.0 + candidates.append((q, tag)) + + # Sort by q descending, stable. + candidates.sort(key=lambda pair: -pair[0]) + + supported_lower = {loc.lower(): loc for loc in self.supported} + for _, tag in candidates: + if tag in supported_lower: + return supported_lower[tag] + primary = tag.split("-", 1)[0] + if primary in supported_lower: + return supported_lower[primary] + return None +``` + +- [ ] **Step 5: Run and verify all tests pass** + +```bash +cd framework/hosting && uv run pytest tests/test_locale_middleware.py -v +``` + +Expected: 7 passed. + +- [ ] **Step 6: Commit** + +```bash +git add framework/hosting/simple_module_hosting/settings.py \ + framework/hosting/simple_module_hosting/i18n_middleware.py \ + framework/hosting/tests/test_locale_middleware.py +git commit -m "feat(hosting): add LocaleMiddleware with cookie + Accept-Language fallback" +``` + +--- + +## Task 6: `TranslatorDep` + registry wiring in `app_builder` + +**Files:** +- Create: `framework/hosting/simple_module_hosting/i18n_deps.py` +- Create: `framework/hosting/tests/test_translator_dep.py` +- Modify: `framework/hosting/simple_module_hosting/app_builder.py` + +**Context:** Wire the `I18nRegistry` into `app.state`, install `LocaleMiddleware`, and expose a `TranslatorDep` FastAPI dependency. The registry is built in Phase 3 (app creation) from each module's `locale_dirs()` so endpoints registered in later phases can use `TranslatorDep`. + +- [ ] **Step 1: Write failing dep-injection test** + +Create `framework/hosting/tests/test_translator_dep.py`: + +```python +"""Tests for TranslatorDep end-to-end via a minimal app.""" + +from __future__ import annotations + +from fastapi import FastAPI +from starlette.testclient import TestClient + +from simple_module_core.i18n import I18nRegistry, Translator +from simple_module_hosting.i18n_deps import TranslatorDep +from simple_module_hosting.i18n_middleware import LocaleMiddleware + + +def _build_app() -> FastAPI: + reg = I18nRegistry(default_locale="en", supported_locales=["en", "es"]) + reg._messages = { # noqa: SLF001 + "en": {"hello": "Hello, {name}"}, + "es": {"hello": "Hola, {name}"}, + } + + app = FastAPI() + app.state.i18n_registry = reg + app.state.settings_default_locale = "en" + + @app.get("/hi") + def hi(t: TranslatorDep, name: str = "friend") -> dict[str, str]: + return {"greeting": t.t("hello", name=name), "locale": t.locale} + + app.add_middleware( + LocaleMiddleware, + supported_locales=["en", "es"], + default_locale="en", + ) + return app + + +def test_translator_dep_uses_request_locale() -> None: + client = TestClient(_build_app()) + resp = client.get("/hi?name=Ana", cookies={"locale": "es"}) + assert resp.json() == {"greeting": "Hola, Ana", "locale": "es"} + + +def test_translator_dep_falls_back_to_default_locale() -> None: + client = TestClient(_build_app()) + resp = client.get("/hi?name=Ana") # no cookie, no Accept-Language + assert resp.json() == {"greeting": "Hello, Ana", "locale": "en"} + + +def test_translator_dep_returned_is_translator_instance() -> None: + app = _build_app() + + @app.get("/type") + def type_check(t: TranslatorDep) -> dict[str, bool]: + return {"is_translator": isinstance(t, Translator)} + + client = TestClient(app) + assert client.get("/type").json() == {"is_translator": True} +``` + +- [ ] **Step 2: Run and verify failure** + +```bash +cd framework/hosting && uv run pytest tests/test_translator_dep.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'simple_module_hosting.i18n_deps'`. + +- [ ] **Step 3: Implement `TranslatorDep`** + +Create `framework/hosting/simple_module_hosting/i18n_deps.py`: + +```python +"""FastAPI dependency for request-scoped Translator resolution.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request +from simple_module_core.i18n import Translator + + +async def get_translator(request: Request) -> Translator: + """Resolve a Translator bound to ``request.state.locale``. + + Reads the registry from ``request.app.state.i18n_registry`` and the + default locale from ``request.app.state.settings_default_locale`` + (populated by create_app). + + ``request.state.locale`` is populated by LocaleMiddleware. + """ + registry = request.app.state.i18n_registry + default_locale = request.app.state.settings_default_locale + locale = getattr(request.state, "locale", default_locale) + return Translator(registry, locale=locale, default_locale=default_locale) + + +TranslatorDep = Annotated[Translator, Depends(get_translator)] +``` + +- [ ] **Step 4: Run test to confirm dep works in isolation** + +```bash +cd framework/hosting && uv run pytest tests/test_translator_dep.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 5: Wire i18n into `app_builder`** + +In `framework/hosting/simple_module_hosting/app_builder.py`: + +First add the import at the top, near the other `simple_module_core` imports (around line 26-29): + +```python +from simple_module_core.i18n import I18nRegistry +``` + +Then add to the middleware import (around line 43-49): + +```python +from simple_module_hosting.i18n_middleware import LocaleMiddleware +``` + +After line 146 (`health_registry = HealthRegistry()`), add i18n registry construction: + +```python + i18n_registry = I18nRegistry( + default_locale=settings.i18n_default_locale, + supported_locales=settings.i18n_supported_locales, + ) + for mod in modules: + for namespace, locale_dir in mod.locale_dirs().items(): + i18n_registry.add_source(namespace, locale_dir) + # Host-level locales live at /host/locales/. + host_locales = _PROJECT_ROOT / "host" / "locales" + if host_locales.is_dir(): + i18n_registry.add_source("host", host_locales) + # Shared UI package locales. + ui_locales = _PROJECT_ROOT / "packages" / "ui" / "locales" + if ui_locales.is_dir(): + i18n_registry.add_source("ui", ui_locales) + i18n_registry.load() +``` + +After line 172 (`app.state.settings = settings`), add: + +```python + app.state.i18n_registry = i18n_registry + app.state.settings_default_locale = settings.i18n_default_locale +``` + +In the middleware block (around line 218-234), add `LocaleMiddleware` immediately before `InertiaLayoutDataMiddleware`. Since middleware is added in reverse execution order, `LocaleMiddleware` must be added **after** `InertiaLayoutDataMiddleware` to run before it. Replace the existing `add_middleware(InertiaLayoutDataMiddleware, ...)` call with this pair: + +```python + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=menu_registry, + permission_registry=perm_registry, + ) + app.add_middleware( + LocaleMiddleware, + supported_locales=settings.i18n_supported_locales, + default_locale=settings.i18n_default_locale, + cookie_name=settings.i18n_cookie_name, + ) +``` + +- [ ] **Step 6: Extend `InertiaLayoutDataMiddleware` to include locale in shared props** + +In `framework/hosting/simple_module_hosting/middleware.py`, edit the `shared` dict in `InertiaLayoutDataMiddleware.__call__` (around line 232). The registry is on `request.app.state.i18n_registry`. Replace the `shared: dict = {` block with: + +```python + registry = getattr(request.app.state, "i18n_registry", None) + locale = getattr(request.state, "locale", None) + if registry is not None and locale is not None: + i18n_block = { + "locale": locale, + "supportedLocales": registry.supported_locales, + "messages": registry.messages(locale), + } + else: + i18n_block = { + "locale": "en", + "supportedLocales": ["en"], + "messages": {}, + } + + shared: dict = { + "auth": { + "user": ( + { + "id": user.id, + "name": user.name, + "email": user.email, + "roles": user.roles, + } + if user + else None + ), + "isAuthenticated": is_authenticated, + "permissions": frontend_permissions, + }, + "menus": self.menu_registry.get_for_user( + is_authenticated=is_authenticated, + roles=roles, + ), + "csrf_token": secrets.token_urlsafe(32) if is_authenticated else "", + "i18n": i18n_block, + } +``` + +- [ ] **Step 7: Run full hosting test suite** + +```bash +cd framework/hosting && uv run pytest -v +``` + +Expected: all tests pass (including the existing `test_app.py`). If `test_app.py` fails with `AttributeError` on `i18n_registry`, it means an assertion there needs updating — add a minimal check that the registry exists but do not break existing coverage. + +- [ ] **Step 8: Commit** + +```bash +git add framework/hosting/simple_module_hosting/i18n_deps.py \ + framework/hosting/simple_module_hosting/app_builder.py \ + framework/hosting/simple_module_hosting/middleware.py \ + framework/hosting/tests/test_translator_dep.py +git commit -m "feat(hosting): wire I18nRegistry, LocaleMiddleware, TranslatorDep" +``` + +--- + +## Task 7: Emit `generated-resources.ts` for frontend typing + +**Files:** +- Create: `framework/hosting/simple_module_hosting/i18n_manifest.py` +- Create: `framework/hosting/tests/test_i18n_manifest.py` +- Modify: `framework/hosting/simple_module_hosting/app_builder.py` + +**Context:** Emit a `generated-resources.ts` file alongside `modules.generated.ts` whose only job is to give i18next a typed shape to augment against. Values are empty strings; only the keys matter. + +- [ ] **Step 1: Write failing test** + +Create `framework/hosting/tests/test_i18n_manifest.py`: + +```python +"""Tests for generated-resources.ts emission.""" + +from __future__ import annotations + +from pathlib import Path + +from simple_module_core.i18n import I18nRegistry +from simple_module_hosting.i18n_manifest import write_generated_resources + + +def test_writes_file_with_flat_keys(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = { # noqa: SLF001 + "en": { + "host.landing.title": "Hello", + "products.browse.title": "Products", + } + } + out = write_generated_resources(reg, tmp_path) + text = out.read_text() + assert "'host.landing.title': ''" in text + assert "'products.browse.title': ''" in text + assert "AUTO-GENERATED" in text + assert "export default" in text + + +def test_keys_are_sorted(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = {"en": {"z.a": "", "a.z": "", "m.m": ""}} # noqa: SLF001 + out = write_generated_resources(reg, tmp_path) + text = out.read_text() + a_idx = text.index("'a.z'") + m_idx = text.index("'m.m'") + z_idx = text.index("'z.a'") + assert a_idx < m_idx < z_idx + + +def test_only_writes_when_changed(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = {"en": {"k": "v"}} # noqa: SLF001 + out = write_generated_resources(reg, tmp_path) + first_mtime = out.stat().st_mtime_ns + # Second call with identical content should not re-touch the file. + write_generated_resources(reg, tmp_path) + assert out.stat().st_mtime_ns == first_mtime +``` + +- [ ] **Step 2: Run and verify failure** + +```bash +cd framework/hosting && uv run pytest tests/test_i18n_manifest.py -v +``` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the manifest writer** + +Create `framework/hosting/simple_module_hosting/i18n_manifest.py`: + +```python +"""Emit generated-resources.ts for frontend TypeScript type augmentation.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from simple_module_core.i18n import I18nRegistry + +logger = logging.getLogger(__name__) + +_HEADER = """\ +// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. +// Regenerate by booting the host in development mode. +// +// Shape of the default locale's keys. Values are empty strings; only the +// key set is consumed by TypeScript via i18n-types.ts module augmentation. +""" + + +def write_generated_resources(registry: I18nRegistry, output_dir: Path) -> Path: + """Write ``generated-resources.ts`` into ``output_dir``. + + Emits the default-locale key shape with empty string values, so i18next's + ``CustomTypeOptions['resources']`` augmentation infers the available keys. + Writes only when content differs from what's on disk. + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + target = output_dir / "generated-resources.ts" + + default_locale = registry.default_locale + messages = registry.messages(default_locale) + keys = sorted(messages.keys()) + + lines = [_HEADER, "", "export default {", " translation: {"] + for key in keys: + # Single-quote key, empty-string value, trailing comma for diff-friendliness. + lines.append(f" '{key}': '',") + lines.append(" },") + lines.append("} as const;") + lines.append("") + payload = "\n".join(lines) + + try: + existing = target.read_text(encoding="utf-8") + except FileNotFoundError: + existing = None + + if existing != payload: + target.write_text(payload, encoding="utf-8") + logger.info("Wrote %s (%d keys)", target.name, len(keys)) + + return target +``` + +- [ ] **Step 4: Run tests** + +```bash +cd framework/hosting && uv run pytest tests/test_i18n_manifest.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 5: Wire emission into `app_builder`** + +In `framework/hosting/simple_module_hosting/app_builder.py`, find the block that emits `modules.generated.ts` (the `write_module_pages_manifest` call around lines 130-139). Immediately after it, add generation of resources: + +```python + try: + from simple_module_hosting.i18n_manifest import write_generated_resources + + if client_app.is_dir(): + write_generated_resources(i18n_registry, client_app) + except Exception: + logger.exception( + "Failed to write generated-resources.ts — frontend types will be stale" + ) +``` + +- [ ] **Step 6: Commit** + +```bash +git add framework/hosting/simple_module_hosting/i18n_manifest.py \ + framework/hosting/simple_module_hosting/app_builder.py \ + framework/hosting/tests/test_i18n_manifest.py +git commit -m "feat(hosting): emit generated-resources.ts for frontend i18next typing" +``` + +--- + +## Task 8: `packages/i18n` workspace package + +**Files:** +- Create: `packages/i18n/package.json` +- Create: `packages/i18n/tsconfig.json` +- Create: `packages/i18n/src/index.ts` +- Modify: `host/client_app/package.json` +- Modify: `packages/ui/package.json` + +**Context:** A thin wrapper around `i18next` + `react-i18next` exposing only the four symbols the rest of the app needs: `configureI18n`, `updateI18n`, `useT`, `t`. + +- [ ] **Step 1: Create `package.json` for the workspace** + +Create `packages/i18n/package.json`: + +```json +{ + "name": "@simple-module/i18n", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "i18next": "^23.15.0", + "react": "^19.0.0", + "react-i18next": "^15.1.0" + }, + "devDependencies": { + "@simple-module/tsconfig": "*" + } +} +``` + +- [ ] **Step 2: Create `tsconfig.json`** + +Create `packages/i18n/tsconfig.json`: + +```json +{ + "extends": "@simple-module/tsconfig/base.json", + "include": ["src"] +} +``` + +Check `packages/tsconfig/base.json` exists and has reasonable React TS settings. If it doesn't already include `"jsx": "react-jsx"`, add it. + +- [ ] **Step 3: Write `src/index.ts`** + +Create `packages/i18n/src/index.ts`: + +```ts +/** + * Localization primitives — thin wrapper over i18next + react-i18next. + * + * Exports the surface the rest of the host consumes: + * - configureI18n: call once at boot with the active-locale messages + * - updateI18n: call when an Inertia visit brings a new locale + * - useT: React hook for translation + * - t: non-hook accessor (for use in schemas or utilities) + */ + +import i18next from 'i18next'; +import { initReactI18next, useTranslation } from 'react-i18next'; + +type Messages = Record; + +interface ConfigureOptions { + locale: string; + messages: Messages; +} + +let configured = false; + +export function configureI18n(opts: ConfigureOptions): void { + if (configured) { + updateI18n(opts); + return; + } + i18next.use(initReactI18next).init({ + lng: opts.locale, + fallbackLng: opts.locale, + resources: { + [opts.locale]: { translation: opts.messages }, + }, + interpolation: { + escapeValue: false, // React already escapes + prefix: '{', + suffix: '}', + }, + returnNull: false, + }); + configured = true; +} + +export function updateI18n(opts: ConfigureOptions): void { + i18next.addResourceBundle( + opts.locale, + 'translation', + opts.messages, + /* deep */ true, + /* overwrite */ true, + ); + if (i18next.language !== opts.locale) { + i18next.changeLanguage(opts.locale); + } +} + +export { useTranslation as useT } from 'react-i18next'; +export { t } from 'i18next'; +``` + +- [ ] **Step 4: Add dependencies to `host/client_app/package.json`** + +Edit `host/client_app/package.json`. In `dependencies`, add: + +```json + "@simple-module/i18n": "*", + "i18next": "^23.15.0", + "react-i18next": "^15.1.0", +``` + +(place alphabetically; `@simple-module/i18n` near `@inertiajs/react`) + +- [ ] **Step 5: Add the dep to `packages/ui/package.json`** + +Edit `packages/ui/package.json`. In `dependencies`, add: + +```json + "@simple-module/i18n": "*", +``` + +- [ ] **Step 6: Install JS deps** + +```bash +npm install +``` + +Expected: packages resolve; new workspace member `@simple-module/i18n` shows as linked. + +- [ ] **Step 7: Verify TypeScript compiles** + +```bash +npx tsc --noEmit -p host/client_app/tsconfig.json +``` + +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add packages/i18n host/client_app/package.json packages/ui/package.json package-lock.json +git commit -m "feat(i18n): add @simple-module/i18n workspace package" +``` + +--- + +## Task 9: `host/client_app/i18n.ts` + `i18n-types.ts` + stub `generated-resources.ts` + +**Files:** +- Create: `host/client_app/i18n.ts` +- Create: `host/client_app/i18n-types.ts` +- Create: `host/client_app/generated-resources.ts` (stub — real one emitted at boot) +- Modify: `host/client_app/main.tsx` +- Modify: `host/client_app/app.tsx` + +**Context:** Wire i18next into the Inertia root. `i18n-types.ts` activates the TS augmentation; `i18n.ts` handles the boot call and locale-change detection on navigation. + +- [ ] **Step 1: Create the stub `generated-resources.ts`** + +Create `host/client_app/generated-resources.ts`: + +```ts +// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. +// This file is replaced each time the host boots in development mode. The +// stub committed to git holds only the keys we need for type-checking when +// CI runs before the backend boots. + +export default { + translation: {} as Record, +} as const; +``` + +- [ ] **Step 2: Create `i18n-types.ts`** + +Create `host/client_app/i18n-types.ts`: + +```ts +/** + * Activate i18next TypeScript module augmentation. + * + * Imported once from main.tsx so t('foo.bar') is type-checked against + * generated-resources.ts. Runtime effect: none. + */ + +import 'i18next'; +import type resources from './generated-resources'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'translation'; + resources: typeof resources; + } +} +``` + +- [ ] **Step 3: Create `i18n.ts`** + +Create `host/client_app/i18n.ts`: + +```ts +/** + * Initial wiring for @simple-module/i18n inside the Inertia app. + * + * Reads {locale, messages} from Inertia shared props and calls + * configureI18n on boot; on every successful navigation, checks whether + * the active locale changed and updates the i18next resources. + */ + +import { router, type PageProps } from '@inertiajs/react'; +import { configureI18n, updateI18n } from '@simple-module/i18n'; + +interface I18nSharedProps { + locale: string; + supportedLocales: string[]; + messages: Record; +} + +export function bootI18nFromInitialPage(props: PageProps): void { + const i18n = (props as unknown as { i18n?: I18nSharedProps }).i18n; + if (!i18n) { + configureI18n({ locale: 'en', messages: {} }); + return; + } + configureI18n({ locale: i18n.locale, messages: i18n.messages }); +} + +let activeLocale: string | null = null; + +export function subscribeI18nToNavigation(): () => void { + return router.on('success', (event) => { + const i18n = (event.detail.page.props as unknown as { i18n?: I18nSharedProps }).i18n; + if (!i18n) return; + if (i18n.locale !== activeLocale) { + updateI18n({ locale: i18n.locale, messages: i18n.messages }); + activeLocale = i18n.locale; + } + }); +} +``` + +- [ ] **Step 4: Import types in `main.tsx`** + +Edit `host/client_app/main.tsx`: + +```ts +import './styles.css'; +import './i18n-types'; +import './app'; +``` + +- [ ] **Step 5: Wire `configureI18n` + subscription in `app.tsx`** + +Edit `host/client_app/app.tsx`. Replace the existing file content with: + +```tsx +import { createInertiaApp, router } from '@inertiajs/react'; +import { ErrorBoundary } from '@simple-module/ui/components/ErrorBoundary'; +import { useEffect, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n'; +import { resolvePage } from './pages'; + +createInertiaApp({ + resolve: async (name) => { + const page = await resolvePage(name); + return page; + }, + setup({ el, App, props }) { + bootI18nFromInitialPage(props.initialPage.props); + + function Root() { + const boundaryRef = useRef(null); + + useEffect(() => { + const stopReset = router.on('navigate', () => boundaryRef.current?.reset()); + const stopI18n = subscribeI18nToNavigation(); + return () => { + stopReset(); + stopI18n(); + }; + }, []); + + return ( + + + + ); + } + + createRoot(el).render(); + }, + progress: { + color: '#4B5563', + delay: 150, + }, +}); +``` + +- [ ] **Step 6: Run typecheck** + +```bash +npx tsc --noEmit -p host/client_app/tsconfig.json +``` + +Expected: no errors. The empty `generated-resources.ts` stub means any `t()` key currently compiles (no keys to check against); narrowing comes once the backend boots and overwrites the stub. + +- [ ] **Step 7: Commit** + +```bash +git add host/client_app/i18n.ts host/client_app/i18n-types.ts \ + host/client_app/generated-resources.ts host/client_app/main.tsx \ + host/client_app/app.tsx +git commit -m "feat(client): wire @simple-module/i18n into Inertia boot" +``` + +--- + +## Task 10: `.gitignore` generated files + +**Files:** +- Modify: `.gitignore` (root) + +**Context:** `generated-resources.ts` is written every time the host boots in dev, like `modules.generated.ts` and friends. Keep the stub in git so fresh checkouts compile, but ignore the in-place edits. + +- [ ] **Step 1: Check existing ignored generated files** + +```bash +grep -n "modules.generated" .gitignore +``` + +Expected: entries for the existing generated files. If the generated files are NOT ignored (they may be committed on purpose so CI sees them), skip this task entirely and commit nothing. + +- [ ] **Step 2: If they are ignored, add our generated file** + +Append to `.gitignore`: + +``` +# Generated by simple_module_hosting.i18n_manifest at boot; stub is checked in +host/client_app/generated-resources.ts +``` + +**Stop and ask:** if `modules.generated.ts` is already committed (grep above returned no match), do NOT add this line — commit the generated file too for consistency. If `modules.generated.ts` is in `.gitignore`, add the line above. + +- [ ] **Step 3: Commit (if modified)** + +```bash +git add .gitignore +git commit -m "chore: ignore generated-resources.ts like other manifest outputs" +``` + +--- + +## Task 11: Switcher endpoint + +**Files:** +- Create: `host/routes_i18n.py` +- Modify: `host/main.py` +- Create: `host/tests/test_routes_i18n.py` + +**Context:** `POST /i18n/set-locale` validates the locale against supported, sets a 1-year cookie, and 303-redirects to `Referer`. + +- [ ] **Step 1: Find where host-level routes are registered** + +```bash +grep -n "include_router" host/main.py host/routes.py +``` + +Note the wiring. `host/routes.py` exports `router`; `host/main.py` should include it into `app`. + +- [ ] **Step 2: Write failing endpoint test** + +Create `host/tests/test_routes_i18n.py`: + +```python +"""Tests for the locale-switcher endpoint.""" + +from __future__ import annotations + +from fastapi import FastAPI +from starlette.testclient import TestClient + +from host.routes_i18n import router as i18n_router + + +def _build_app(supported: list[str]) -> FastAPI: + app = FastAPI() + app.state.settings_supported_locales = supported + app.state.settings_cookie_name = "locale" + app.include_router(i18n_router) + return app + + +def test_sets_cookie_on_valid_locale() -> None: + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post( + "/i18n/set-locale", + data={"locale": "es"}, + headers={"Referer": "/dashboard"}, + ) + assert resp.status_code == 303 + assert resp.headers["location"] == "/dashboard" + cookie = resp.cookies.get("locale") + assert cookie == "es" + + +def test_rejects_unsupported_locale() -> None: + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post("/i18n/set-locale", data={"locale": "de"}) + assert resp.status_code == 422 + + +def test_redirects_to_root_when_no_referer() -> None: + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post("/i18n/set-locale", data={"locale": "es"}) + assert resp.status_code == 303 + assert resp.headers["location"] == "/" +``` + +- [ ] **Step 3: Run and verify failure** + +```bash +uv run pytest host/tests/test_routes_i18n.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'host.routes_i18n'`. + +- [ ] **Step 4: Implement the router** + +Create `host/routes_i18n.py`: + +```python +"""Locale switcher endpoint. + +POST /i18n/set-locale with form body ``locale=``. Validates against +the host's supported locales, sets a 1-year cookie, and 303-redirects to +the Referer (falls back to ``/``). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Form, HTTPException, Request +from starlette.responses import RedirectResponse + +router = APIRouter() + +_ONE_YEAR_SECONDS = 60 * 60 * 24 * 365 + + +@router.post("/i18n/set-locale", response_model=None) +async def set_locale(request: Request, locale: str = Form(...)) -> RedirectResponse: + """Persist the user's locale choice in a long-lived cookie.""" + supported: list[str] = request.app.state.settings_supported_locales + cookie_name: str = request.app.state.settings_cookie_name + + if locale not in supported: + raise HTTPException( + status_code=422, + detail=f"Unsupported locale '{locale}' (supported: {', '.join(supported)})", + ) + + destination = request.headers.get("referer") or "/" + response = RedirectResponse(destination, status_code=303) + response.set_cookie( + key=cookie_name, + value=locale, + max_age=_ONE_YEAR_SECONDS, + path="/", + samesite="lax", + httponly=False, + ) + return response +``` + +- [ ] **Step 5: Wire `settings_supported_locales` / `settings_cookie_name` into `app.state`** + +Edit `framework/hosting/simple_module_hosting/app_builder.py`. In the `app.state` block added in Task 6 (after `app.state.settings_default_locale = settings.i18n_default_locale`), add: + +```python + app.state.settings_supported_locales = settings.i18n_supported_locales + app.state.settings_cookie_name = settings.i18n_cookie_name +``` + +- [ ] **Step 6: Include the router in the host** + +Edit `host/main.py`. Find where `host.routes.router` is included and add the new router beside it. If the include is in `host/main.py`: + +```python +from host.routes import router as host_router +from host.routes_i18n import router as i18n_router + +# ... in startup: +app.include_router(host_router) +app.include_router(i18n_router) +``` + +If instead it's in `host/routes.py` via a meta-router, add the include there. (Check `host/main.py` first.) + +- [ ] **Step 7: Run tests** + +```bash +uv run pytest host/tests/test_routes_i18n.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 8: Commit** + +```bash +git add host/routes_i18n.py host/main.py host/tests/test_routes_i18n.py \ + framework/hosting/simple_module_hosting/app_builder.py +git commit -m "feat(host): add POST /i18n/set-locale switcher endpoint" +``` + +--- + +## Task 12: `` component + +**Files:** +- Create: `packages/ui/src/components/LocaleSwitcher.tsx` +- Modify: `packages/ui/src/layouts/AuthenticatedLayout.tsx` +- Modify: `packages/ui/src/layouts/PublicLayout.tsx` + +**Context:** A shadcn `DropdownMenu` that reads `i18n.locale` / `i18n.supportedLocales` from Inertia shared props and submits a hidden form to `/i18n/set-locale` when the user picks a language. Labels for each locale are in that locale's own language (hardcoded small map). + +- [ ] **Step 1: Inspect the existing DropdownMenu component** + +```bash +ls packages/ui/src/components/ui | grep -i dropdown +``` + +Confirm `dropdown-menu.tsx` exists. If not, pick the closest-available: `select.tsx` also works. + +- [ ] **Step 2: Write the switcher** + +Create `packages/ui/src/components/LocaleSwitcher.tsx`: + +```tsx +import { usePage } from '@inertiajs/react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@simple-module/ui/components/ui/dropdown-menu'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Globe } from 'lucide-react'; +import { useRef } from 'react'; + +/** + * Static map of locale code -> label in that locale's own language. + * Picked before the user can read the current UI language, so labels + * must not depend on t(). + */ +const LOCALE_LABELS: Record = { + en: 'English', + es: 'Español', + de: 'Deutsch', + fr: 'Français', + pt: 'Português', + ja: '日本語', + zh: '中文', + ru: 'Русский', +}; + +interface I18nSharedProps { + locale: string; + supportedLocales: string[]; + messages: Record; +} + +export function LocaleSwitcher() { + const page = usePage<{ i18n?: I18nSharedProps }>(); + const i18n = page.props.i18n; + const formRef = useRef(null); + + if (!i18n || i18n.supportedLocales.length <= 1) { + return null; + } + + const select = (locale: string) => { + if (locale === i18n.locale) return; + const form = formRef.current; + if (!form) return; + const input = form.elements.namedItem('locale') as HTMLInputElement; + input.value = locale; + form.submit(); + }; + + return ( + <> + + + + + + + + + {i18n.supportedLocales.map((code) => ( + select(code)} + data-active={code === i18n.locale} + > + {LOCALE_LABELS[code] ?? code} + {code === i18n.locale && ✓} + + ))} + + + + ); +} +``` + +- [ ] **Step 3: Mount it in `AuthenticatedLayout`** + +Find the top-right area of `packages/ui/src/layouts/AuthenticatedLayout.tsx` (usually near the user menu). Import: + +```tsx +import { LocaleSwitcher } from '@simple-module/ui/components/LocaleSwitcher'; +``` + +Add `` next to the existing user menu trigger. If there's already a user-dropdown component, add the switcher immediately before it. + +- [ ] **Step 4: Mount it in `PublicLayout`** + +Repeat for `packages/ui/src/layouts/PublicLayout.tsx`. Place the switcher in the top-right header area. + +- [ ] **Step 5: Verify typecheck** + +```bash +npx tsc --noEmit -p host/client_app/tsconfig.json +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/ui/src/components/LocaleSwitcher.tsx \ + packages/ui/src/layouts/AuthenticatedLayout.tsx \ + packages/ui/src/layouts/PublicLayout.tsx +git commit -m "feat(ui): add LocaleSwitcher dropdown in layouts" +``` + +--- + +## Task 13: Vitest setup + +**Files:** +- Create: `vitest.config.ts` (root) +- Create: `vitest.setup.ts` (root) +- Modify: `package.json` (root) +- Modify: `Makefile` + +**Context:** Introduce Vitest as the frontend unit-test runner. Tests live next to source (`*.test.ts`, `*.test.tsx`). Fold into `make test`. + +- [ ] **Step 1: Install Vitest + Testing Library** + +```bash +npm install --save-dev --workspace-root \ + vitest@^2.1.0 \ + @testing-library/react@^16.1.0 \ + @testing-library/jest-dom@^6.6.0 \ + jsdom@^25.0.0 +``` + +If `--workspace-root` isn't recognized by your npm version, run plain `npm install --save-dev ...` from the repo root (editing the root `package.json` directly is fine too — just match the key names and versions). + +- [ ] **Step 2: Create `vitest.setup.ts`** + +Create `vitest.setup.ts` at the repo root: + +```ts +import '@testing-library/jest-dom/vitest'; +``` + +- [ ] **Step 3: Create `vitest.config.ts`** + +Create `vitest.config.ts` at the repo root: + +```ts +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react-swc'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [react(), tsconfigPaths()], + test: { + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + globals: true, + include: [ + 'packages/**/*.test.ts', + 'packages/**/*.test.tsx', + 'host/client_app/**/*.test.ts', + 'host/client_app/**/*.test.tsx', + ], + }, +}); +``` + +- [ ] **Step 4: Add `test` script to root `package.json`** + +Edit the root `package.json`: + +```json + "scripts": { + "dev": "npm run --workspace host/client_app dev", + "build": "npm run --workspace host/client_app build", + "lint": "biome check .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit -p host/client_app/tsconfig.json", + "test": "vitest run" + }, +``` + +- [ ] **Step 5: Add `test-js` to `Makefile`** + +Edit `Makefile`. Update `.PHONY` to include `test-js`: + +```makefile +.PHONY: install install-py install-js dev dev-api dev-ui build test test-js lint doctor ... +``` + +Change the `test` target to run both: + +```makefile +# Testing +test: test-py test-js + +test-py: + uv run pytest + +test-js: + npm test +``` + +Remove the old single-line `test:` recipe (if it stood alone with `uv run pytest`). Add `test-py` as a new target containing that command. + +- [ ] **Step 6: Smoke-test Vitest on an empty suite** + +```bash +npm test -- --passWithNoTests +``` + +Expected: vitest runs and reports 0 tests (exit 0). + +If `--passWithNoTests` is not recognized, create a trivial placeholder test: + +```ts +// packages/i18n/src/smoke.test.ts +import { test, expect } from 'vitest'; +test('vitest is wired', () => { expect(1 + 1).toBe(2); }); +``` + +- [ ] **Step 7: Commit** + +```bash +git add vitest.config.ts vitest.setup.ts package.json Makefile package-lock.json +git commit -m "chore: add Vitest test runner for frontend" +``` + +--- + +## Task 14: Unit tests for `@simple-module/i18n` + +**Files:** +- Create: `packages/i18n/src/configure.test.ts` +- (Delete `packages/i18n/src/smoke.test.ts` if you created it in Task 13.) + +**Context:** Verify `configureI18n` + `t()` basic behavior + plurals. + +- [ ] **Step 1: Write tests** + +Create `packages/i18n/src/configure.test.ts`: + +```ts +import { beforeEach, describe, expect, test } from 'vitest'; +import { configureI18n, t, updateI18n } from './index'; + +describe('configureI18n', () => { + beforeEach(() => { + configureI18n({ + locale: 'en', + messages: { + 'hello': 'Hello', + 'greeting': 'Hello, {name}', + 'items_one': '{count} item', + 'items_other': '{count} items', + }, + }); + }); + + test('returns string for known key', () => { + expect(t('hello')).toBe('Hello'); + }); + + test('interpolates named placeholders', () => { + expect(t('greeting', { name: 'Ana' })).toBe('Hello, Ana'); + }); + + test('picks _one variant for count=1', () => { + expect(t('items', { count: 1 })).toBe('1 item'); + }); + + test('picks _other variant for count>1', () => { + expect(t('items', { count: 5 })).toBe('5 items'); + }); + + test('returns the key when unknown', () => { + expect(t('missing.key' as unknown as never)).toBe('missing.key'); + }); +}); + +describe('updateI18n', () => { + test('swaps the active locale', () => { + configureI18n({ locale: 'en', messages: { hello: 'Hello' } }); + updateI18n({ locale: 'es', messages: { hello: 'Hola' } }); + expect(t('hello')).toBe('Hola'); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +```bash +npm test +``` + +Expected: 6 passed. + +- [ ] **Step 3: Remove the smoke test if present** + +```bash +rm -f packages/i18n/src/smoke.test.ts +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/i18n/src/configure.test.ts +[ -f packages/i18n/src/smoke.test.ts ] && git rm packages/i18n/src/smoke.test.ts +git commit -m "test(i18n): unit tests for configureI18n, t(), updateI18n, plurals" +``` + +--- + +## Task 15: `LocaleSwitcher` component test + +**Files:** +- Create: `packages/ui/src/components/LocaleSwitcher.test.tsx` + +**Context:** Render-test using Inertia's `usePage` mocked. Verify that the dropdown lists all supported locales and marks the active one. + +- [ ] **Step 1: Write the test** + +Create `packages/ui/src/components/LocaleSwitcher.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +// Mock @inertiajs/react's usePage before importing the component under test. +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ + props: { + i18n: { + locale: 'en', + supportedLocales: ['en', 'es'], + messages: {}, + }, + }, + }), +})); + +import { LocaleSwitcher } from './LocaleSwitcher'; + +describe('LocaleSwitcher', () => { + test('renders when multiple locales supported', () => { + render(); + expect(screen.getByRole('button', { name: /change language/i })).toBeInTheDocument(); + }); + + test('form targets /i18n/set-locale', () => { + const { container } = render(); + const form = container.querySelector('form'); + expect(form?.getAttribute('action')).toBe('/i18n/set-locale'); + expect(form?.getAttribute('method')?.toLowerCase()).toBe('post'); + }); +}); + + +describe('LocaleSwitcher — single locale', () => { + beforeEach(() => { + vi.doMock('@inertiajs/react', () => ({ + usePage: () => ({ + props: { + i18n: { locale: 'en', supportedLocales: ['en'], messages: {} }, + }, + }), + })); + }); + + test('does not render when only one locale supported', async () => { + const { LocaleSwitcher: SingleSwitcher } = await import('./LocaleSwitcher'); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); +``` + +(The single-locale test uses `vi.doMock` + dynamic import because the module mock in the top block is hoisted and reused otherwise. If this pattern fails in your Vitest version, drop the "single locale" describe block — the behavior is also covered implicitly by the `supportedLocales.length <= 1` guard.) + +- [ ] **Step 2: Run tests** + +```bash +npm test +``` + +Expected: all previous + new tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add packages/ui/src/components/LocaleSwitcher.test.tsx +git commit -m "test(ui): LocaleSwitcher renders and targets switcher endpoint" +``` + +--- + +## Task 16: `I18nDiagnostics` + +**Files:** +- Create: `framework/core/simple_module_core/diagnostics/_i18n.py` +- Modify: `framework/core/simple_module_core/diagnostics/__init__.py` +- Modify: `framework/core/simple_module_core/diagnostics/_runner.py` +- Create: `framework/core/tests/test_i18n_diagnostics.py` + +**Context:** New diagnostic class that validates key parity between locale files, JSON validity, and nesting structure. Hooked into `run_diagnostics` so `make doctor` runs it automatically. + +- [ ] **Step 1: Write failing tests** + +Create `framework/core/tests/test_i18n_diagnostics.py`: + +```python +"""Tests for I18nDiagnostics.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from simple_module_core.diagnostics._i18n import I18nDiagnostics + + +class _FakeModule: + def __init__(self, name: str, dirs: dict[str, Path]) -> None: + self.meta = type("Meta", (), {"name": name})() + self._dirs = dirs + + def locale_dirs(self) -> dict[str, Path]: + return self._dirs + + +def _write(dir_: Path, lang: str, data: dict) -> None: + dir_.mkdir(parents=True, exist_ok=True) + (dir_ / f"{lang}.json").write_text(json.dumps(data)) + + +def test_reports_missing_locale_file(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1"}) + # no es.json + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM013" in codes + + +def test_reports_missing_keys_in_non_default_locale(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1", "b": "2"}) + _write(tmp_path / "p", "es", {"a": "1"}) # missing 'b' + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM014" in codes + assert any("b" in (f.message or "") for f in findings if f.code == "SM014") + + +def test_reports_extra_keys_in_non_default_locale(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1"}) + _write(tmp_path / "p", "es", {"a": "1", "extra": "x"}) + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM015" in codes + + +def test_reports_invalid_json(tmp_path: Path) -> None: + (tmp_path / "p").mkdir() + (tmp_path / "p" / "en.json").write_text("{ not json") + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM016" in codes + + +def test_no_findings_when_keys_match(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1"}) + _write(tmp_path / "p", "es", {"a": "1"}) + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + assert findings == [] + + +def test_module_without_locales_is_silently_skipped(tmp_path: Path) -> None: + mod = _FakeModule("P", {}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + assert findings == [] +``` + +- [ ] **Step 2: Run and verify failure** + +```bash +cd framework/core && uv run pytest tests/test_i18n_diagnostics.py -v +``` + +Expected: ImportError. + +- [ ] **Step 3: Implement `I18nDiagnostics`** + +Create `framework/core/simple_module_core/diagnostics/_i18n.py`: + +```python +"""Diagnostics that validate i18n locale file coverage and consistency.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel +from simple_module_core.i18n import flatten_messages + +if TYPE_CHECKING: + from simple_module_core.module import ModuleBase + + +class I18nDiagnostics: + """Validates locale file coverage per module. + + Codes: + - SM013: missing locale file for a supported locale. + - SM014: non-default locale is missing keys present in the default. + - SM015: non-default locale has keys not present in the default. + - SM016: locale JSON fails to parse or has non-string leaves. + """ + + def __init__(self, supported_locales: list[str], default_locale: str) -> None: + self.supported_locales = list(supported_locales) + self.default_locale = default_locale + + def run(self, modules: list[ModuleBase]) -> list[Diagnostic]: + findings: list[Diagnostic] = [] + for mod in modules: + for namespace, locale_dir in mod.locale_dirs().items(): + findings.extend(self._check_namespace(mod.meta.name, namespace, Path(locale_dir))) + return findings + + def _check_namespace( + self, module_name: str, namespace: str, locale_dir: Path + ) -> list[Diagnostic]: + findings: list[Diagnostic] = [] + per_locale_keys: dict[str, set[str]] = {} + + for locale in self.supported_locales: + path = locale_dir / f"{locale}.json" + if not path.is_file(): + findings.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM013", + message=( + f"Missing locale file {locale}.json for namespace '{namespace}'" + ), + module_name=module_name, + file=str(path), + suggestion=f"Create {path} (even if empty: '{{}}')", + ) + ) + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("top-level JSON must be an object") + flat = flatten_messages(raw) + except (json.JSONDecodeError, ValueError) as exc: + findings.append( + Diagnostic( + level=DiagnosticLevel.ERROR, + code="SM016", + message=f"Invalid locale JSON in {path}: {exc}", + module_name=module_name, + file=str(path), + ) + ) + continue + per_locale_keys[locale] = set(flat.keys()) + + default_keys = per_locale_keys.get(self.default_locale, set()) + for locale, keys in per_locale_keys.items(): + if locale == self.default_locale: + continue + missing = default_keys - keys + extra = keys - default_keys + if missing: + findings.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM014", + message=( + f"Locale '{locale}' in namespace '{namespace}' is missing keys: " + f"{', '.join(sorted(missing))}" + ), + module_name=module_name, + ) + ) + if extra: + findings.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM015", + message=( + f"Locale '{locale}' in namespace '{namespace}' has keys not in " + f"default: {', '.join(sorted(extra))}" + ), + module_name=module_name, + ) + ) + return findings +``` + +- [ ] **Step 4: Export from diagnostics package** + +Edit `framework/core/simple_module_core/diagnostics/__init__.py`: + +```python +from simple_module_core.diagnostics._i18n import I18nDiagnostics +from simple_module_core.diagnostics._migration import MigrationDiagnostics +from simple_module_core.diagnostics._module import ModuleDiagnostics +from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics +from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel + +__all__ = [ + "Diagnostic", + "DiagnosticLevel", + "I18nDiagnostics", + "MigrationDiagnostics", + "ModuleDiagnostics", + "print_diagnostics", + "run_diagnostics", +] +``` + +- [ ] **Step 5: Wire into `run_diagnostics`** + +Edit `framework/core/simple_module_core/diagnostics/_runner.py`. Extend `run_diagnostics` with optional i18n params: + +```python +def run_diagnostics( + modules: list[ModuleBase], + *, + migration_state: dict | None = None, + module_tables: set[str] | None = None, + migrated_tables: set[str] | None = None, + i18n_supported_locales: list[str] | None = None, + i18n_default_locale: str | None = None, +) -> list[Diagnostic]: + """Convenience function to run all diagnostics.""" + diagnostics = ModuleDiagnostics().run(modules) + + if i18n_supported_locales and i18n_default_locale: + from simple_module_core.diagnostics._i18n import I18nDiagnostics + diagnostics.extend( + I18nDiagnostics( + supported_locales=i18n_supported_locales, + default_locale=i18n_default_locale, + ).run(modules) + ) + + if migration_state is not None: + # ... existing logic unchanged + migration_diag = MigrationDiagnostics() + diagnostics.extend( + migration_diag.check_revision_mismatch( + current_revision=migration_state.get("current_revision"), + head_revision=migration_state.get("head_revision"), + ) + ) + if module_tables is not None and migrated_tables is not None: + diagnostics.extend( + migration_diag.check_table_coverage(module_tables, migrated_tables) + ) + + return diagnostics +``` + +- [ ] **Step 6: Pass i18n settings from `app_builder`** + +Edit `framework/hosting/simple_module_hosting/app_builder.py`. In the diagnostics block (around line 122-128), pass the new kwargs: + +```python + diagnostics = run_diagnostics( + modules, + i18n_supported_locales=settings.i18n_supported_locales, + i18n_default_locale=settings.i18n_default_locale, + ) +``` + +- [ ] **Step 7: Run all tests** + +```bash +cd framework/core && uv run pytest -v +``` + +Expected: all tests pass (new + existing). + +- [ ] **Step 8: Commit** + +```bash +git add framework/core/simple_module_core/diagnostics/_i18n.py \ + framework/core/simple_module_core/diagnostics/__init__.py \ + framework/core/simple_module_core/diagnostics/_runner.py \ + framework/core/tests/test_i18n_diagnostics.py \ + framework/hosting/simple_module_hosting/app_builder.py +git commit -m "feat(diagnostics): add I18nDiagnostics for locale key parity" +``` + +--- + +## Task 17: Extract strings from `packages/ui` + +**Files:** +- Create: `packages/ui/locales/en.json` +- Create: `packages/ui/locales/es.json` +- Modify: `packages/ui/src/components/ErrorScreen.tsx` +- Modify: `packages/ui/src/components/PageShell.tsx` (if it has strings) +- Modify: `packages/ui/src/components/ui/empty.tsx` (leave — Empty is a primitive) + +**Context:** Pull shared UI strings into `ui.*` namespace. ErrorScreen is the main source; Empty/PageShell are content-agnostic. + +- [ ] **Step 1: Audit which UI components have hardcoded strings** + +```bash +cd packages/ui && grep -rn -E '"[A-Z][a-z]{2,}' src/components --include='*.tsx' | grep -v 'ui/' | head -40 +``` + +Note any user-facing strings in components you own (not the shadcn `ui/` primitives). + +- [ ] **Step 2: Create `packages/ui/locales/en.json`** + +Create `packages/ui/locales/en.json` with every discovered string, example: + +```json +{ + "errors": { + "generic_title": "Something went wrong", + "generic_description": "An unexpected error occurred. Please try again.", + "retry_button": "Try again", + "go_home_button": "Go home" + }, + "switcher": { + "label": "Change language" + } +} +``` + +(Adjust to what you actually find. If `ErrorScreen.tsx` uses different wording, match that verbatim so behavior is preserved.) + +- [ ] **Step 3: Create the Spanish counterpart** + +Create `packages/ui/locales/es.json` with the same keys: + +```json +{ + "errors": { + "generic_title": "Algo salió mal", + "generic_description": "Ocurrió un error inesperado. Inténtalo de nuevo.", + "retry_button": "Reintentar", + "go_home_button": "Ir al inicio" + }, + "switcher": { + "label": "Cambiar idioma" + } +} +``` + +- [ ] **Step 4: Swap hardcoded strings in `ErrorScreen.tsx`** + +Edit `packages/ui/src/components/ErrorScreen.tsx`. At the top: + +```tsx +import { useT } from '@simple-module/i18n'; +``` + +In the component body, replace each hardcoded string with `t('ui.errors.generic_title')`, etc. + +- [ ] **Step 5: Replace the aria-label in the switcher** + +Edit `packages/ui/src/components/LocaleSwitcher.tsx`. Import `useT` and swap the `aria-label="Change language"` for `t('ui.switcher.label')`. (The labels for each locale stay hardcoded in `LOCALE_LABELS`.) + +- [ ] **Step 6: Run typecheck** + +```bash +npx tsc --noEmit -p host/client_app/tsconfig.json +``` + +Note: `t()` will type-check against the empty stub `generated-resources.ts` which accepts any string. Once the backend boots and emits the real one, keys will narrow. This is expected — document in the task summary. + +- [ ] **Step 7: Commit** + +```bash +git add packages/ui/locales packages/ui/src/components/ +git commit -m "feat(ui): extract shared UI strings into packages/ui/locales" +``` + +--- + +## Task 18: Extract strings from `modules/auth` + +**Files:** +- Create: `modules/auth/auth/locales/en.json` +- Create: `modules/auth/auth/locales/es.json` +- Modify: `modules/auth/auth/module.py` (add `locale_dirs()`) +- Modify: `modules/auth/auth/endpoints/*.py` (use `TranslatorDep`) + +**Context:** Auth has flash messages ("Login failed", etc.) and possibly email content. Extract them. + +- [ ] **Step 1: Inventory strings** + +```bash +grep -rn -E '"[A-Z][a-z]{3,}' modules/auth/auth --include='*.py' | head +``` + +- [ ] **Step 2: Create `en.json`** + +Create `modules/auth/auth/locales/en.json` with a `flash.*` / `errors.*` structure matching what you found. Example: + +```json +{ + "flash": { + "login_success": "Welcome back!", + "logout_success": "You have been signed out." + }, + "errors": { + "invalid_credentials": "Invalid email or password", + "session_expired": "Your session has expired. Please sign in again." + } +} +``` + +- [ ] **Step 3: Create `es.json`** + +Create `modules/auth/auth/locales/es.json` with Spanish counterparts for every key. + +- [ ] **Step 4: Add `locale_dirs()` to the module class** + +Edit `modules/auth/auth/module.py`. Add imports if missing: + +```python +import importlib.resources +from pathlib import Path +``` + +Add method to the `AuthModule` class: + +```python + def locale_dirs(self) -> dict[str, Path]: + return {"auth": Path(str(importlib.resources.files(__package__) / "locales"))} +``` + +- [ ] **Step 5: Swap hardcoded strings in endpoints for `TranslatorDep`** + +For each endpoint in `modules/auth/auth/endpoints/` that produces a user-facing string, inject `t: TranslatorDep` and use `t.t("auth.errors.invalid_credentials")` instead of the literal. Example for a login endpoint: + +```python +from simple_module_hosting.i18n_deps import TranslatorDep + +@router.post("/login") +async def login(..., t: TranslatorDep) -> ...: + if not user: + raise HTTPException(status_code=401, detail=t.t("auth.errors.invalid_credentials")) + flash(request, t.t("auth.flash.login_success")) +``` + +- [ ] **Step 6: Run auth tests** + +```bash +uv run pytest modules/auth/tests -v +``` + +Update tests that asserted on English strings — either assert against the (locale-aware) key or assert on the English default (locale defaults to `en` in tests). + +- [ ] **Step 7: Commit** + +```bash +git add modules/auth +git commit -m "feat(auth): localize flash/error messages via TranslatorDep" +``` + +--- + +## Task 19: Extract strings from `modules/dashboard` + +**Files:** +- Create: `modules/dashboard/dashboard/locales/en.json` +- Create: `modules/dashboard/dashboard/locales/es.json` +- Modify: `modules/dashboard/dashboard/module.py` (add `locale_dirs()`) +- Modify: `modules/dashboard/dashboard/pages/*.tsx` (use `useT()`) + +**Context:** Dashboard is a thin module — mostly page titles and nav labels. + +- [ ] **Step 1: Inventory strings** + +```bash +grep -rn -E '"[A-Z][a-z]{3,}' modules/dashboard/dashboard --include='*.tsx' --include='*.py' +``` + +- [ ] **Step 2: Create `en.json` and `es.json`** + +Create both files under `modules/dashboard/dashboard/locales/`. Use a structure matching the pages, e.g.: + +```json +{ + "home": { + "title": "Dashboard", + "welcome": "Welcome back, {name}" + } +} +``` + +- [ ] **Step 3: Add `locale_dirs()` to `DashboardModule`** + +Same pattern as Task 18 Step 4. + +- [ ] **Step 4: Swap strings in pages** + +In `modules/dashboard/dashboard/pages/*.tsx`, import `useT` and swap literals for `t('dashboard.home.title')`, etc. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest modules/dashboard/tests -v +``` + +- [ ] **Step 6: Commit** + +```bash +git add modules/dashboard +git commit -m "feat(dashboard): localize page strings" +``` + +--- + +## Task 20: Extract strings from `modules/products` + host landing/error + +**Files:** +- Create: `modules/products/products/locales/en.json` +- Create: `modules/products/products/locales/es.json` +- Modify: `modules/products/products/module.py` +- Modify: `modules/products/products/pages/Browse.tsx` +- Modify: `modules/products/products/pages/Create.tsx` +- Modify: `modules/products/products/pages/Edit.tsx` +- Modify: `modules/products/products/pages/validation.ts` +- Create: `host/locales/en.json` +- Create: `host/locales/es.json` +- Modify: `host/client_app/pages/Landing.tsx` +- Modify: `host/client_app/pages/Error.tsx` + +**Context:** Products is the biggest module with strings in 3 pages plus validation schemas. Host has Landing + Error pages. + +- [ ] **Step 1: Create `modules/products/products/locales/en.json`** + +Based on the strings already visible in `Browse.tsx` (lines 103, 121, 122, 129, 196-200, 226-230, 242): + +```json +{ + "browse": { + "title": "Products", + "description": "Manage your product catalog", + "new_button": "New Product", + "search_placeholder": "Search products...", + "count_one": "{count} product", + "count_other": "{count} products", + "empty_title": "No products yet", + "empty_description": "Get started by creating your first product.", + "create_button": "Create Product", + "no_match": "No products match \"{query}\"" + }, + "table": { + "name": "Name", + "description": "Description", + "price": "Price", + "status": "Status", + "actions": "Actions", + "active": "Active", + "inactive": "Inactive" + }, + "delete_dialog": { + "title": "Delete \"{name}\"?", + "description": "This action cannot be undone. This will permanently delete the product from the catalog.", + "cancel": "Cancel", + "confirm": "Delete" + }, + "toasts": { + "deleted": "\"{name}\" deleted", + "delete_failed": "Failed to delete product" + }, + "validation": { + "name_required": "Name is required", + "price_positive": "Price must be greater than zero" + } +} +``` + +- [ ] **Step 2: Create `modules/products/products/locales/es.json`** + +Translate every key. Machine translation is fine for this initial commit. + +- [ ] **Step 3: Add `locale_dirs()` to `ProductsModule`** + +Edit `modules/products/products/module.py`. Add at top: + +```python +import importlib.resources +from pathlib import Path +``` + +Add to the class: + +```python + def locale_dirs(self) -> dict[str, Path]: + return {"products": Path(str(importlib.resources.files(__package__) / "locales"))} +``` + +- [ ] **Step 4: Swap strings in `Browse.tsx`** + +Edit `modules/products/products/pages/Browse.tsx`. Add import: + +```tsx +import { useT } from '@simple-module/i18n'; +``` + +Inside the `Browse` function body, after the `usePage` destructure: + +```tsx + const { t } = useT(); +``` + +Replace every literal with a `t()` call. Examples: + +- `title="Products"` → `title={t('products.browse.title')}` +- `description="Manage your product catalog"` → `description={t('products.browse.description')}` +- `"Search products..."` → `t('products.browse.search_placeholder')` +- The pluralized count line (lines 128-130) becomes: + ```tsx + {pagination.total > 0 && ( +

+ {t('products.browse.count', { count: pagination.total })} +

+ )} + ``` +- Column headers → `t('products.table.name')`, etc. +- Badge text `'Active'`/`'Inactive'` → `t('products.table.active')` / `t('products.table.inactive')` +- AlertDialog title → `t('products.delete_dialog.title', { name: product.name })` +- Description → `t('products.delete_dialog.description')` +- Cancel/Delete buttons → `t('products.delete_dialog.cancel')` / `t('products.delete_dialog.confirm')` +- Toast messages in `handleDelete`: + ```tsx + onSuccess: () => toast.success(t('products.toasts.deleted', { name: product.name })), + onError: () => toast.error(t('products.toasts.delete_failed')), + ``` +- Empty state copy → `t('products.browse.empty_title')`, `t('products.browse.empty_description')`, `t('products.browse.create_button')` +- No-match copy → `t('products.browse.no_match', { query: search })` + +- [ ] **Step 5: Swap strings in `Create.tsx` and `Edit.tsx`** + +Same pattern as Step 4 for each file. Read the file first, inventory strings, add `useT`, swap. + +- [ ] **Step 6: Convert `validation.ts` to a hook** + +Edit `modules/products/products/pages/validation.ts`. If it currently exports a plain schema, wrap it: + +```ts +import { useT } from '@simple-module/i18n'; +import { z } from 'zod'; + +export function useProductSchema() { + const { t } = useT(); + return z.object({ + name: z.string().min(1, t('products.validation.name_required')), + price: z.coerce.number().positive(t('products.validation.price_positive')), + }); +} +``` + +Update callers in `Create.tsx` and `Edit.tsx` to call `useProductSchema()` inside the component. + +- [ ] **Step 7: Create `host/locales/en.json`** + +Inventory strings from `host/client_app/pages/Landing.tsx` (8.8 KB) and `Error.tsx`. Example skeleton: + +```json +{ + "landing": { + "hero_title": "Build modular apps in Python", + "hero_subtitle": "Plugin modules that compose at boot.", + "cta_login": "Sign in", + "cta_learn_more": "Learn more" + }, + "error": { + "500_title": "Something went wrong", + "500_description": "We've been notified and will investigate.", + "404_title": "Page not found", + "404_description": "The page you're looking for doesn't exist." + } +} +``` + +Fill in the actual strings from the pages — do not leave placeholder wording. + +- [ ] **Step 8: Create `host/locales/es.json`** + +Spanish counterparts. + +- [ ] **Step 9: Swap strings in `Landing.tsx` and `Error.tsx`** + +Same pattern as Step 4. + +- [ ] **Step 10: Run all tests** + +```bash +uv run pytest -q +npm test +``` + +Expected: all green. If pytest complains that Products endpoints broke, check that tests aren't asserting against English literals that you removed. + +- [ ] **Step 11: Commit** + +```bash +git add modules/products host/locales host/client_app/pages +git commit -m "feat: localize products module, landing, and error pages" +``` + +--- + +## Task 21: Update scaffolder to generate localized modules + +**Files:** +- Modify: `scripts/new_module.py` +- Modify: `scripts/_templates_py.py` +- Modify: `scripts/_templates_tsx.py` + +**Context:** A freshly scaffolded module should be localizable from day one. Emit `locales/en.json` and have templates reference `useT()` / `TranslatorDep`. + +- [ ] **Step 1: Read the existing templates** + +```bash +head -80 scripts/_templates_py.py scripts/_templates_tsx.py +``` + +Understand the current placeholder substitution pattern (likely `.format()` or `{name}` replacements). + +- [ ] **Step 2: Add `locale_dirs()` to the module.py template** + +Edit `scripts/_templates_py.py`. In `module_py(ctx)`, add: +1. At the top of the imports block, add `import importlib.resources` and `from pathlib import Path` (if not already present). +2. Inside the class body, after `register_routes`, add: + +```python + def locale_dirs(self) -> dict[str, Path]: + return {{"{pkg}": Path(str(importlib.resources.files(__package__) / "locales"))}} +``` + +Use the existing template's placeholder style (verify by reading the file first). + +- [ ] **Step 3: Create a locale template function** + +In `scripts/_templates_py.py`, add a new function near the bottom: + +```python +def locales_en_json(ctx: ScaffoldContext) -> str: + return """\ +{ + "browse": { + "title": "%s", + "description": "Manage your %s", + "new_button": "New %s", + "search_placeholder": "Search %s...", + "empty_title": "No %s yet", + "empty_description": "Get started by creating your first %s.", + "create_button": "Create %s" + }, + "table": { + "actions": "Actions" + }, + "toasts": { + "created": "%s created", + "updated": "%s updated", + "deleted": "%s deleted" + } +} +""" % ( + ctx.class_name, # title + ctx.name, # description plural + ctx.singular_class, # new button + ctx.name, # search placeholder + ctx.name, # empty title + ctx.singular, # empty description + ctx.singular_class, # create button + ctx.singular_class, # toast created + ctx.singular_class, # toast updated + ctx.singular_class, # toast deleted + ) +``` + +- [ ] **Step 4: Update the scaffolder to emit locales** + +Edit `scripts/new_module.py`. Import the new template: + +```python +from _templates_py import ( + ScaffoldContext, + deps_py, + locales_en_json, + models_py, + module_py, + package_init, + pyproject_toml, + service_py, +) +``` + +In `scaffold_module`, after the `create_file(src_dir / "pages" / "Edit.tsx", ...)` line (around line 111), add: + +```python + create_file(src_dir / "locales" / "en.json", locales_en_json(ctx)) +``` + +- [ ] **Step 5: Update the TSX templates to use `useT()`** + +Edit `scripts/_templates_tsx.py`. For each of `browse_tsx`, `create_tsx`, `edit_tsx`: + +1. Add import `import { useT } from '@simple-module/i18n';` +2. Inside the component, add `const { t } = useT();` as the first line after props destructuring. +3. Swap hardcoded strings for `t('..')` — e.g. `title="{ctx.class_name}"` becomes `title={{t('{ctx.pkg}.browse.title')}}` (in the template's placeholder-aware syntax). + +Be conservative: only swap strings that have matching keys in the `locales_en_json` template above. Anything without a corresponding key can stay hardcoded (the module author will extract it later). + +- [ ] **Step 6: Sanity-check by scaffolding a throwaway module** + +```bash +python scripts/new_module.py test_scaffold_123 +ls modules/test_scaffold_123/test_scaffold_123/locales +cat modules/test_scaffold_123/test_scaffold_123/locales/en.json +grep -c "useT" modules/test_scaffold_123/test_scaffold_123/pages/Browse.tsx +``` + +Expected: `locales/en.json` exists; `useT` appears in Browse/Create/Edit. + +Clean up: + +```bash +rm -rf modules/test_scaffold_123 +# Revert changes to host/pyproject.toml and pyproject.toml: +git checkout host/pyproject.toml pyproject.toml +``` + +- [ ] **Step 7: Commit** + +```bash +git add scripts/new_module.py scripts/_templates_py.py scripts/_templates_tsx.py +git commit -m "feat(scaffolder): generate locales/en.json and t()-using pages" +``` + +--- + +## Task 22: Documentation + +**Files:** +- Modify: `docs/framework-conventions.md` +- Modify: `README.md` + +**Context:** Module authors need a single place to learn the i18n conventions. Keep it concise. + +- [ ] **Step 1: Append Internationalization section to `framework-conventions.md`** + +Add at the bottom: + +```markdown +## Internationalization + +Modules ship translations as JSON under `/locales/.json` and +declare them via `ModuleBase.locale_dirs()`: + +```python +def locale_dirs(self) -> dict[str, Path]: + return {"orders": importlib.resources.files(__package__) / "locales"} +``` + +### Key naming + +Keys are namespaced by the module and hierarchical by area. Convention: +`..` — e.g. `orders.browse.title`. Use snake_case +for leaves. + +### Interpolation + +Placeholders use `{name}` syntax: + +```json +{ "greeting": "Hello, {name}" } +``` + +```tsx +t('orders.greeting', { name: user.name }) +``` + +```python +t.t("orders.greeting", name=user.name) +``` + +Missing placeholders are left as `{name}` rather than raising. + +### Pluralization + +Suffix keys with CLDR categories (`_zero`, `_one`, `_two`, `_few`, `_many`, +`_other`); only `_other` is required. Pass `count` as a param: + +```json +{ + "items_one": "{count} item", + "items_other": "{count} items" +} +``` + +```tsx +t('orders.items', { count: items.length }) +``` + +Backend uses Babel's CLDR plural rules; frontend uses i18next's `Intl.PluralRules` +— both follow the same CLDR categories, so behavior matches. + +### Validation messages + +Validation messages must be constructed *inside* a React hook so they +pick up the active locale: + +```ts +export function useProductSchema() { + const { t } = useT(); + return z.object({ name: z.string().min(1, t('products.validation.name_required')) }); +} +``` + +Do NOT declare `const schema = z.object({ ... t('...') })` at module scope — +it will resolve against whatever locale was active at first render, forever. + +### Host and shared-package strings + +- Host strings (landing page, error page) live in `host/locales/` and are + namespaced `host.*`. +- Shared UI strings (`packages/ui/`) live in `packages/ui/locales/`, + namespaced `ui.*`. + +### Diagnostics + +`make doctor` checks: + +- **SM013:** missing locale file for a declared supported locale. +- **SM014:** non-default locale missing keys present in the default. +- **SM015:** non-default locale has keys not in the default. +- **SM016:** locale JSON fails to parse. + +Warnings in dev; errors fail boot in production. + +### Supported locales + +Configure via env: + +``` +SM_I18N_DEFAULT_LOCALE=en +SM_I18N_SUPPORTED_LOCALES=en,es,de +SM_I18N_COOKIE_NAME=locale +``` + +Locales not in `SM_I18N_SUPPORTED_LOCALES` are rejected by the switcher +endpoint and ignored by `LocaleMiddleware`. +``` + +- [ ] **Step 2: Add bullet to `README.md`** + +In the Architecture section of `README.md` (around line 110), add a bullet after the Diagnostics bullet: + +```markdown +- **Internationalization**: per-module `locales/.json` files merged at boot into `I18nRegistry`. Frontend uses `i18next` with type-safe keys; backend uses `Babel` for CLDR plurals. Locale resolved per request via cookie → `Accept-Language` → `SM_I18N_DEFAULT_LOCALE`. See `framework-conventions.md` → Internationalization. +``` + +Add to the "Common commands" table if relevant (no new command needed — `make doctor` covers it). + +- [ ] **Step 3: Commit** + +```bash +git add docs/framework-conventions.md README.md +git commit -m "docs: document i18n conventions and settings" +``` + +--- + +## Task 23: End-to-end smoke verification + +**Files:** None (verification-only task). + +**Context:** Boot the app, click through the flow, make sure every part works together. This is the final gate before calling the feature done. + +- [ ] **Step 1: Set up `.env` with a second locale** + +Ensure `.env` has: + +``` +SM_I18N_DEFAULT_LOCALE=en +SM_I18N_SUPPORTED_LOCALES=["en","es"] +``` + +(The JSON-list format is what pydantic-settings parses for list fields.) + +- [ ] **Step 2: Start the dev servers** + +```bash +make kill # ensure clean slate +make dev +``` + +Wait for both API and Vite to report ready. + +- [ ] **Step 3: Verify `generated-resources.ts` was emitted** + +```bash +head -30 host/client_app/generated-resources.ts +wc -l host/client_app/generated-resources.ts +``` + +Expected: populated file with a large key set (not the empty stub). Count should be ~40-200 lines depending on how many strings were extracted. + +- [ ] **Step 4: Check typecheck now sees real keys** + +```bash +# Introduce a typo somewhere temporarily to verify narrowing works. +# Example: edit modules/products/products/pages/Browse.tsx to call +# t('products.browse.titel') instead of 'title'. +npx tsc --noEmit -p host/client_app/tsconfig.json +``` + +Expected: TypeScript reports an error on the typo. Revert the typo. + +- [ ] **Step 5: Browser smoke** + +Open `http://localhost:8000`: + +1. Land on `/` in English. +2. Switcher visible in top-right; pick "Español". +3. Page reloads, strings are Spanish. +4. Log in → navigate to `/dashboard`, then `/products`. All strings Spanish. +5. Refresh browser — still Spanish (cookie is persisted with long expiry). +6. Open DevTools Network → confirm Inertia responses include `props.i18n.locale === "es"`. + +- [ ] **Step 6: Run diagnostics** + +```bash +make doctor +``` + +Expected: no SM013/SM014/SM015/SM016 findings. Any warnings should be addressed before declaring done. + +- [ ] **Step 7: Run full test suite** + +```bash +make test # pytest + vitest +make lint +``` + +Expected: all green. + +- [ ] **Step 8: Commit (only if any fixes were needed)** + +If you had to fix anything during smoke, commit it with a descriptive message. If everything passed first try, no commit needed. + +```bash +# Example if you had to fix a missing key: +git add +git commit -m "fix(i18n): add missing dashboard welcome key" +``` + +- [ ] **Step 9: Final cleanup** + +Check for any straggler TODO/FIXME comments introduced during the work: + +```bash +grep -rn "TODO\|FIXME\|XXX" framework/core/simple_module_core/i18n.py \ + framework/hosting/simple_module_hosting/i18n_*.py \ + host/routes_i18n.py \ + packages/i18n packages/ui/src/components/LocaleSwitcher.tsx +``` + +Expected: no findings. + +--- + +## Acceptance Criteria (from spec — verify at end) + +- [ ] All of `modules/auth`, `modules/dashboard`, `modules/products`, `host/`, `packages/ui/` have their user-facing strings in `locales/en.json` and `locales/es.json`. +- [ ] `t('products.browse.title')` works in React; an unknown key is a TS compile error (after the backend boots and emits `generated-resources.ts`). +- [ ] `t.t("auth.errors.invalid_credentials")` works in FastAPI endpoints via `TranslatorDep`. +- [ ] `POST /i18n/set-locale` with `locale=es` sets the cookie and subsequent loads are Spanish. +- [ ] Inertia shared props include `{locale, supportedLocales, messages}` on every page. +- [ ] `make doctor` reports no i18n issues. +- [ ] `make test` (pytest + Vitest) passes. +- [ ] `make new-module name=orders` produces a module whose pages use `useT()` and whose `locales/en.json` matches the scaffolded template strings. +- [ ] `framework-conventions.md` has an Internationalization section. diff --git a/docs/superpowers/specs/2026-04-15-i18n-localization-design.md b/docs/superpowers/specs/2026-04-15-i18n-localization-design.md new file mode 100644 index 00000000..0a39536a --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-i18n-localization-design.md @@ -0,0 +1,502 @@ +# i18n Localization — Design + +**Date:** 2026-04-15 +**Status:** Approved for planning +**Scope:** Unified frontend + backend localization system driven by per-module JSON files, with type-safe key access on the frontend. + +## Summary + +Add end-to-end localization to the framework: React UI strings, FastAPI user-facing messages, email/flash content, and page titles all resolve through a shared, module-contributed JSON registry. Frontend uses `i18next` + `react-i18next`; backend uses Python `Babel` for CLDR plural rules. Locale is selected via a cookie that an authenticated or anonymous user sets through a switcher in the layout. Type safety: unknown translation keys are a TypeScript compile error; params are untyped. + +## Goals + +- Single source of truth per module per locale — one JSON file, read by both Python and TypeScript. +- Per-module contribution that matches the existing modular-monolith pattern (mirrors `template_dirs()`, `static_mounts()`, permission/menu registration). +- Compile-time detection of unknown translation keys on the frontend. +- Correct pluralization across locales via CLDR rules (e.g., Russian's four plural forms). +- Works for pip-installed module wheels, not just the monorepo layout. +- A newly scaffolded module is localizable from the first `make new-module`. + +## Non-Goals + +- Locale-aware date/number/currency formatting (Babel can, but it's a separate concern with its own UX questions; design so it can slot in later). +- Translation management tooling integration (Crowdin, Lokalise, Phrase). JSON files are edited by hand. +- RTL layout support. +- Per-user locale preference stored on the user model (cookie-based selection works uniformly for anonymous + authenticated; layering user-preference storage comes later without breaking this design). +- Machine-translation pipeline at runtime or in CI. +- Lazy-loading locales on the frontend (one locale's messages ship per request via Inertia shared props; payload is small). + +## Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Scope | Frontend + backend unified | Single source of truth; one translator rhythm for module authors. | +| File location | Per-module + host + UI package | Matches existing per-module template/static pattern. | +| Locale selection | Cookie, set by a switcher UI | Works for anonymous + authenticated users uniformly; no DB changes. | +| Type safety | Key existence only (level A) | Simple codegen; params as `Record`. | +| Pluralization | Simple plurals (`_one`/`_other`/...), i18next-style | Covers real-world 95% case. | +| Frontend library | `i18next` + `react-i18next` | Mature, first-class TS augmentation for typed keys. | +| Backend library | `Babel` | CLDR plural rules; future-proofs date/number formatting. | +| JSON split | One combined file per locale per module (not split client/server) | Minor over-ship to client is worth the simpler mental model. | +| Frontend tests | Introduce Vitest | Project has no existing JS test runner; this feature justifies adding one. | + +## Architecture + +### File Layout + +``` +modules/// + locales/ + en.json + es.json + .json + +host/ + locales/ + en.json # landing page, error page, switcher labels + es.json + +packages/ui/ + locales/ + en.json # PageShell, ErrorBoundary, Empty, etc. + es.json + +packages/i18n/ # NEW workspace package + package.json + tsconfig.json + src/ + index.ts # configureI18n(), useT(), t() re-exports + *.test.ts + +host/client_app/ # host-level host-app additions + i18n.ts # NEW: initial-props wiring for configureI18n + i18n-types.ts # NEW: i18next module augmentation (app-level) + generated-resources.ts # NEW (generated): default-locale key shape + +framework/core/simple_module_core/ + i18n.py # NEW: I18nRegistry, Translator + +framework/hosting/simple_module_hosting/ + middleware.py # extended: add LocaleMiddleware + settings.py # extended: SM_I18N_* settings +``` + +### Module Contribution + +`ModuleBase` gains a new optional method, returning a `{namespace: directory}` mapping: + +```python +def locale_dirs(self) -> dict[str, Path]: + """Return {namespace: directory} mapping for locale JSON files. + + Default returns an empty dict. Override to contribute a module's locales: + + return {"products": importlib.resources.files(__package__) / "locales"} + + The namespace becomes the key prefix in the merged registry. A file + `locales/en.json` containing `{"browse": {"title": "Products"}}` becomes + the key `products.browse.title` at runtime. + """ + return {} +``` + +Returning a dict (not a list) lets a module pick its own namespace explicitly. Convention: use the module's lowercase name. There is no framework-imposed default — the namespace is always whatever the module returns. + +### Namespacing + +All keys live under a module-provided namespace. The host contributes `host.*`, the shared UI package contributes `ui.*`, each module contributes `.*`. Namespaces prevent collisions between modules developed independently. + +### JSON Shape + +Module JSON files are nested for human readability; the registry flattens to dotted keys at load time. + +Example `modules/products/products/locales/en.json`: + +```json +{ + "browse": { + "title": "Products", + "search_placeholder": "Search products...", + "count_one": "{count} product", + "count_other": "{count} products", + "empty_title": "No products yet", + "empty_description": "Get started by creating your first product." + }, + "actions": { + "delete_confirm_title": "Delete \"{name}\"?", + "delete_confirm_body": "This action cannot be undone. This will permanently delete the product from the catalog." + } +} +``` + +Interpolation placeholders use `{name}` syntax (consistent between frontend and backend). Plural variants use suffixes `_zero`/`_one`/`_two`/`_few`/`_many`/`_other`, matching CLDR categories; only `_other` is required for every pluralized key. + +## Backend + +### I18nRegistry + +```python +# framework/core/simple_module_core/i18n.py + +class I18nRegistry: + """Merged view of all module locale JSON files, keyed by locale.""" + + def __init__(self, default_locale: str, supported_locales: list[str]): ... + + def add_source(self, namespace: str, locale_dir: Path) -> None: + """Queue a module's locale directory for loading under a namespace.""" + + def load(self) -> None: + """Read and flatten all registered JSON files. Called once at boot. + + Logs warnings for missing locale files. Raises in production if a + supported locale has no files anywhere (via diagnostics, not here). + """ + + def available_locales(self) -> list[str]: + """Locales that have at least one loaded JSON file.""" + + def messages(self, locale: str) -> dict[str, str]: + """Flat dotted-key map for the given locale.""" +``` + +Flattening: `{"browse": {"title": "X"}}` under namespace `products` becomes `{"products.browse.title": "X"}`. + +### Translator + +```python +class Translator: + def __init__( + self, + registry: I18nRegistry, + locale: str, + default_locale: str, + ): ... + + def t(self, key: str, **params: Any) -> str: + """Translate a key with optional interpolation and plural resolution. + + 1. If `count` in params and `key` has `_` variants, resolve + the plural form via Babel's PluralRule for the current locale. + 2. Look up the (possibly-pluralized) key in the requested locale. + 3. Fall back to the default locale. + 4. Fall back to returning the key itself (with a dev-mode log warning). + 5. Interpolate `{name}`-style placeholders via str.format_map. + """ +``` + +The plural resolver uses `babel.plural.PluralRule`: + +```python +from babel.plural import PluralRule +from babel import Locale + +rule = Locale.parse(locale).plural_form # Callable[[int|float], str] +category = rule(params["count"]) # "zero" | "one" | ... | "other" +pluralized_key = f"{key}_{category}" +``` + +### Request Pipeline + +1. **`LocaleMiddleware`** — runs very early in the stack, before `InertiaLayoutData`. Resolution order: + 1. Cookie named by `SM_I18N_COOKIE_NAME` (default `locale`), validated against `registry.available_locales()`. + 2. `Accept-Language` header, negotiated against supported locales (use `babel.localedata` or a simple longest-prefix match — implementation detail for the plan). + 3. `SM_I18N_DEFAULT_LOCALE`. + + Sets `request.state.locale`. + +2. **`TranslatorDep`** — a FastAPI dependency returning a `Translator` bound to `request.state.locale`: + + ```python + async def get_translator(request: Request) -> Translator: ... + + TranslatorDep = Annotated[Translator, Depends(get_translator)] + ``` + + Endpoints use it directly: + ```python + async def login(t: TranslatorDep, ...) -> ...: + flash(request, t.t("auth.login.failed")) + ``` + +3. **Inertia shared props** — `InertiaLayoutData` middleware appends: + ```python + { + "locale": request.state.locale, + "supportedLocales": registry.available_locales(), + "messages": registry.messages(request.state.locale), + } + ``` + to the shared props dict. Messages are the pre-flattened dict for the active locale only. + +### Switcher Endpoint + +`POST /i18n/set-locale` in `host/routes.py`: + +- Body: form-encoded `locale=`. +- Validates against `registry.available_locales()`; rejects with 422 otherwise. +- Sets the cookie: `HttpOnly=false` (the frontend may need to read it for optimistic rendering), `SameSite=Lax`, `Path=/`, `Max-Age=31536000` (1 year). Long-lived is the right default — users expect their language choice to survive browser restarts. +- 303-redirects to the `Referer` header, falling back to `/`. + +### Settings Additions + +```python +# In Settings (pydantic-settings model): +i18n_default_locale: str = "en" +i18n_supported_locales: list[str] = ["en"] # comma-separated in env +i18n_cookie_name: str = "locale" +``` + +Environment variable examples: +``` +SM_I18N_DEFAULT_LOCALE=en +SM_I18N_SUPPORTED_LOCALES=en,es,de +SM_I18N_COOKIE_NAME=locale +``` + +### Boot Sequence Integration + +`locale_dirs()` is pure metadata (like `template_dirs()`), not a registration hook. The host's boot sequence iterates each module once, calls `module.locale_dirs()`, and feeds the `{namespace: path}` pairs into `registry.add_source()`. After all modules are visited, `registry.load()` is called to read and flatten every JSON file. This happens in Phase 2 (App creation) alongside other metadata gathering, before Phase 4's registration hooks — because Phase 4 hooks (e.g., endpoints wired up via `register_routes`) may depend on `TranslatorDep` resolving against a populated registry. + +## Frontend + +### `packages/i18n` + +New workspace package, depends on `i18next` and `react-i18next`, consumed as `@simple-module/i18n`. + +```ts +// packages/i18n/src/index.ts +export function configureI18n(opts: { + locale: string; + messages: Record; +}): void; + +export function updateI18n(opts: { + locale: string; + messages: Record; +}): void; // for locale changes after initial boot + +export { useTranslation as useT } from 'react-i18next'; +export { t } from 'i18next'; // for non-hook contexts +``` + +`configureI18n` is called once at app boot with the `{locale, messages}` Inertia shared props, before rendering the React tree. `updateI18n` is called when Inertia navigation brings a new locale (e.g., after the switcher POST). + +### Resource Delivery & Aggregation + +**Runtime data flow:** The frontend trusts Inertia shared props as the sole source of translation data at runtime. The backend's `I18nRegistry.messages(locale)` is serialized into `props.messages` on every Inertia response; `configureI18n` passes that dict directly to i18next. No frontend glob of locale JSON runs in production — this keeps the JS bundle small. + +**Build-time typing:** Type generation still needs to know the full key set of the default locale. The Python host emits `host/client_app/generated-resources.ts` at boot (alongside the existing `modules.generated.ts`), flattening the default locale's merged JSON into an object literal with empty-string values. This file is the shape fed into i18next's TypeScript module augmentation (see "Type Safety" below). It is imported only by `types.ts` for type inference; tree-shaking excludes it from the runtime bundle. + +### Type Safety (Level A) + +i18next supports TypeScript module augmentation to make `t()` strongly typed over key existence. The augmentation lives in `host/client_app/` (consumer), not in `packages/i18n/` (provider) — because the key set depends on which modules are installed, so it's an app-level concern, not a library-level one. `packages/i18n` exports `useT`/`configureI18n` without any key typing; `host/client_app/i18n-types.ts` augments `'i18next'` using a host-local generated file: + +```ts +// host/client_app/i18n-types.ts +import 'i18next'; +import type resources from './generated-resources'; + +declare module 'i18next' { + interface CustomTypeOptions { + resources: typeof resources; + } +} +``` + +`host/client_app/generated-resources.ts` is emitted by the Python host at boot, alongside `modules.generated.ts`. Shape: + +```ts +// host/client_app/generated-resources.ts (generated — do not edit) +export default { + translation: { + 'host.landing.title': '', + 'products.browse.title': '', + 'products.browse.count_one': '', + 'products.browse.count_other': '', + // ... all keys from the default-locale merged JSON, values are empty strings + // (only the key shape matters for typing; values are never read) + }, +} as const; +``` + +`i18n-types.ts` is imported once from `main.tsx` so its augmentation takes effect app-wide. Effect: `t('products.browse.title')` compiles; `t('products.browse.titel')` is a type error. Params remain `Record` (level A). + +### React Integration + +`host/client_app/app.tsx` initializes i18next before rendering: + +```tsx +import { configureI18n } from '@simple-module/i18n'; + +createInertiaApp({ + resolve: async (name) => resolvePage(name), + setup({ el, App, props }) { + // Initial props include locale + messages from the first page response. + const { locale, messages } = props.initialPage.props as any; + configureI18n({ locale, messages }); + // ... existing setup + }, +}); +``` + +Subsequent Inertia visits bring fresh `{locale, messages}`; a `router.on('success')` hook calls `updateI18n` when the incoming locale differs from the current one. + +Page-level usage: + +```tsx +import { useT } from '@simple-module/i18n'; + +function Browse() { + const { t } = useT(); + return

{t('products.browse.title')}

; +} +``` + +### Locale Switcher Component + +`packages/ui/src/components/LocaleSwitcher.tsx` — shadcn `DropdownMenu` + form submit: + +- Props: `{ current: string; supported: string[]; localeLabels?: Record }`. +- Reads `current`/`supported` from Inertia shared props via `usePage()` if no explicit props passed. +- Dropdown items post to `/i18n/set-locale` via a hidden form with CSRF handled by the existing session middleware. +- Rendered in `AuthenticatedLayout` (sidebar/header) and `PublicLayout` (header right). + +Human-readable locale labels (`"English"`, `"Español"`) are NOT generated from JSON keys — they live in a small static map in the component. This is intentional: the user picks a language *before* they can read the current UI language, so labels should be in each locale's own language, not the current UI language. + +### Validation Messages + +Form validation in module pages (e.g., `modules/products/products/pages/validation.ts`) currently contains hardcoded Zod messages. Pattern for localization: + +```ts +import { useT } from '@simple-module/i18n'; +import { z } from 'zod'; + +export function useProductSchema() { + const { t } = useT(); + return z.object({ + name: z.string().min(1, t('products.validation.name_required')), + price: z.coerce.number().positive(t('products.validation.price_positive')), + }); +} +``` + +Validation messages must be created inside a hook, not at module top-level, so they resolve against the current locale. Documented in `framework-conventions.md`. + +## Module Authoring Experience + +### Scaffolder Updates + +`scripts/new_module.py` (`make new-module name=orders`) additionally creates: + +``` +modules/orders/orders/locales/ + en.json # pre-populated with keys matching scaffolded pages +``` + +Scaffolded `en.json` includes every string used by the scaffolded `Browse.tsx`, `Create.tsx`, `Edit.tsx` — page titles, button labels, empty states, toast messages, alert-dialog copy, plus any server-side flash messages from scaffolded endpoints. No hardcoded strings in scaffolded templates; everything uses `t()` / `TranslatorDep`. + +Scaffolded `module.py` includes: + +```python +def locale_dirs(self) -> dict[str, Path]: + from importlib.resources import files + return {"orders": files(__package__) / "locales"} +``` + +### `framework-conventions.md` Additions + +A new "Internationalization" section documenting: + +- Where translation files live per module. +- Key naming convention: `..`, snake_case leaves. +- Interpolation: `{name}` placeholders. +- Pluralization: CLDR-suffixed keys; pass `count` param to `t()`. +- Host/UI strings live in `host/locales/` and `packages/ui/locales/`, namespaced `host.*` / `ui.*`. +- Validation messages must be constructed inside hooks. +- `make doctor` enforces key parity across locales. + +## Diagnostics + +New `I18nDiagnostic` in `framework/core/simple_module_core/diagnostics/`: + +- Every source declared in `add_source()` has at least `.json`. +- For each module, every `.json` that exists has the same top-level key set as the default-locale file. Missing keys in non-default files are warnings; extra keys in non-default files are warnings. +- All JSON files parse cleanly (errors, not warnings). +- JSON files are structurally valid (nested dicts + leaf strings only; no arrays, no numbers). + +Warnings in dev; errors fail boot in production (matches existing diagnostic behavior). + +Invocation via `make doctor` — existing command, new sub-check. + +## Migration of Existing Strings + +In-scope as part of this work: + +- Extract hardcoded strings from `modules/auth/`, `modules/dashboard/`, `modules/products/`, `host/`, and `packages/ui/` into their respective `locales/en.json`. +- Ship `es.json` for every source as a second locale — Spanish translations may be machine-generated for the initial commit. Purpose is to exercise the pipeline end-to-end so `make doctor` has something to diff against and the switcher has a second option to choose. +- Update scaffolded templates in `scripts/new_module.py` to emit `t()`-using versions. + +## Testing Strategy + +### Backend (pytest) + +Lives in `framework/core/tests/test_i18n.py` and `framework/hosting/tests/test_locale_middleware.py`. + +- `I18nRegistry`: flattening nested JSON, namespace prefixing, locale lookup, missing-locale handling, invalid-JSON handling. +- `Translator.t()`: key lookup, fallback to default locale, fallback to key when missing everywhere, `{name}` interpolation, plural resolution for `en` (`_one`/`_other`) and `ru` (`_one`/`_few`/`_many`/`_other`) — verifying Babel drives the resolution. +- `LocaleMiddleware`: cookie → `Accept-Language` → default fallback chain; invalid cookie values dropped; `request.state.locale` populated before Inertia middleware. +- Diagnostics: missing-locale-file detection, missing-key detection, malformed JSON detection. + +Integration tests: + +- `POST /i18n/set-locale` sets the cookie, rejects unsupported locales (422), 303-redirects. +- An endpoint using `TranslatorDep` returns localized flash messages when the cookie is `es`. +- Inertia shared props include the correct `{locale, messages}` for the request's locale. + +Uses existing `conftest.py` fixtures (`db_session`, `authenticated_client`). + +### Frontend (Vitest — newly introduced) + +Root `package.json` gains `vitest`, `@testing-library/react`, `@testing-library/jest-dom` as dev deps. Root `test` script runs `vitest run`. `Makefile` gains a `test-js` target; `make test` runs both `pytest` and `test-js`. + +Initial coverage: + +- `packages/i18n/src/*.test.ts` — `configureI18n()` merges resources into the expected shape; `t()` returns the right string for a known key; unknown key falls back to the key itself; plural resolution picks `_one` vs `_other` for English. +- `packages/ui/src/components/LocaleSwitcher.test.tsx` — renders supported locales from props, submits form on selection, marks the active locale. + +`vitest.config.ts` in `host/client_app/` reuses Vite's resolve/plugin config. + +### End-to-End Smoke (manual checklist in acceptance criteria) + +1. `make dev` → land on `/` in English. +2. Click switcher → pick Spanish → page re-renders in Spanish. +3. Navigate to `/products` → module-scoped strings are Spanish. +4. Refresh → still Spanish (cookie persisted). +5. `make doctor` → no i18n warnings. +6. `make lint` passes (including generated `.d.ts` typing). +7. `make test` (both pytest + Vitest) passes. + +## Acceptance Criteria + +- [ ] All of `modules/auth`, `modules/dashboard`, `modules/products`, `host/`, `packages/ui/` have their user-facing strings extracted to `locales/en.json` and `locales/es.json`. +- [ ] `t('products.browse.title')` works in React; unknown keys are a TS compile error. +- [ ] `t.t("auth.login.failed")` works in FastAPI endpoints via `TranslatorDep`. +- [ ] `POST /i18n/set-locale` with `locale=es` sets the cookie and subsequent page loads render Spanish. +- [ ] Inertia shared props include `{locale, supportedLocales, messages}` on every page. +- [ ] `make doctor` reports no i18n issues against the shipped modules. +- [ ] `make test` passes (pytest + Vitest). +- [ ] `make new-module name=orders` produces a module whose pages use `t()` and whose `locales/en.json` matches the scaffolded template strings; the module is immediately localizable. +- [ ] `framework-conventions.md` has an Internationalization section documenting the conventions. + +## Open Questions + +None — all raised during brainstorming were resolved before spec was written. + +## References + +- `docs/framework-conventions.md` — module authoring invariants this design extends. +- `docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md` — phased boot sequence where `locale_dirs()` integrates. +- `framework/core/simple_module_core/module.py:132` — `template_dirs()` precedent for `locale_dirs()`. +- `framework/hosting/simple_module_hosting/inertia_deps.py` — `InertiaDep` precedent for `TranslatorDep`. +- `host/client_app/pages.ts` — `modules.generated.ts` glob pattern that locale aggregation mirrors. diff --git a/framework/core/pyproject.toml b/framework/core/pyproject.toml index 531af019..5b504989 100644 --- a/framework/core/pyproject.toml +++ b/framework/core/pyproject.toml @@ -7,6 +7,7 @@ authors = [ ] requires-python = ">=3.12" dependencies = [ + "babel>=2.14", "fastapi>=0.115", "packaging>=23.0", "pydantic>=2.0", diff --git a/framework/core/simple_module_core/__init__.py b/framework/core/simple_module_core/__init__.py index 4d59953c..a3334e98 100644 --- a/framework/core/simple_module_core/__init__.py +++ b/framework/core/simple_module_core/__init__.py @@ -22,6 +22,7 @@ ) from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry from simple_module_core.health import HealthCheck, HealthCheckResult, HealthRegistry, HealthStatus +from simple_module_core.i18n import I18nRegistry, Translator from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module import ModuleBase, ModuleMeta from simple_module_core.permissions import PermissionRegistry @@ -40,6 +41,7 @@ "HealthCheckResult", "HealthRegistry", "HealthStatus", + "I18nRegistry", "InvalidModuleError", "MenuItem", "MenuRegistry", @@ -50,6 +52,7 @@ "ModuleMeta", "NotFoundError", "PermissionRegistry", + "Translator", "ValidationError", "check_framework_compatibility", "discover_modules", diff --git a/framework/core/simple_module_core/__main__.py b/framework/core/simple_module_core/__main__.py index 3ef8f27d..21edfab6 100644 --- a/framework/core/simple_module_core/__main__.py +++ b/framework/core/simple_module_core/__main__.py @@ -6,11 +6,18 @@ make doctor # same thing, wrapped 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 ``SM_PROJECT_ROOT`` (or the current working dir). """ from __future__ import annotations +import json +import os import sys +from pathlib import Path from simple_module_core.diagnostics import ( DiagnosticLevel, @@ -20,6 +27,55 @@ from simple_module_core.discovery import discover_modules, topological_sort +def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]: + """Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset. + + Reads env vars directly to avoid a dependency on ``simple_module_hosting``. + Honors ``.env`` by reading it line-by-line if present in the cwd or + ``SM_PROJECT_ROOT`` (pydantic-settings isn't imported here). + """ + root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd()) + dotenv = root / ".env" + if dotenv.is_file(): + for raw in dotenv.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES") + if not supported_raw: + return None, None + + try: + supported = json.loads(supported_raw) + except json.JSONDecodeError: + # Also accept comma-separated (e.g. "en,es,de"). + supported = [s.strip() for s in supported_raw.split(",") if s.strip()] + + if not isinstance(supported, list) or not supported: + return None, None + + default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en") + return supported, default + + +def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]: + """Return ``[(reporter, namespace, path), ...]`` for host + ui locale dirs.""" + root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd()) + out: list[tuple[str, str, Path]] = [] + host_locales = root / "host" / "locales" + if host_locales.is_dir(): + out.append(("host", "host", host_locales)) + ui_locales = root / "packages" / "ui" / "locales" + if ui_locales.is_dir(): + out.append(("packages/ui", "ui", ui_locales)) + return out + + def main() -> int: modules = discover_modules() if not modules: @@ -29,7 +85,15 @@ def main() -> int: # Topological sort surfaces CircularDependencyError early. modules = topological_sort(modules) - diagnostics = run_diagnostics(modules) + supported, default = _load_i18n_settings_from_env() + extra = _discover_extra_locale_sources() + + diagnostics = run_diagnostics( + modules, + i18n_supported_locales=supported, + i18n_default_locale=default, + i18n_extra_sources=extra, + ) print_diagnostics(diagnostics) errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR] diff --git a/framework/core/simple_module_core/diagnostics/__init__.py b/framework/core/simple_module_core/diagnostics/__init__.py index ca9367e8..a2a33718 100644 --- a/framework/core/simple_module_core/diagnostics/__init__.py +++ b/framework/core/simple_module_core/diagnostics/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations +from simple_module_core.diagnostics._i18n import I18nDiagnostics from simple_module_core.diagnostics._migration import MigrationDiagnostics from simple_module_core.diagnostics._module import ModuleDiagnostics from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics @@ -15,6 +16,7 @@ __all__ = [ "Diagnostic", "DiagnosticLevel", + "I18nDiagnostics", "MigrationDiagnostics", "ModuleDiagnostics", "print_diagnostics", diff --git a/framework/core/simple_module_core/diagnostics/_i18n.py b/framework/core/simple_module_core/diagnostics/_i18n.py new file mode 100644 index 00000000..a62b0f71 --- /dev/null +++ b/framework/core/simple_module_core/diagnostics/_i18n.py @@ -0,0 +1,121 @@ +"""Diagnostics that validate i18n locale file coverage and consistency.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel +from simple_module_core.i18n import flatten_messages + +if TYPE_CHECKING: + from simple_module_core.module import ModuleBase + + +class I18nDiagnostics: + """Validates locale file coverage per module. + + Codes: + - SM013: missing locale file for a supported locale. + - SM014: non-default locale is missing keys present in the default. + - SM015: non-default locale has keys not present in the default. + - SM016: locale JSON fails to parse or has non-string leaves. + """ + + def __init__( + self, + supported_locales: list[str], + default_locale: str, + extra_sources: list[tuple[str, str, Path]] | None = None, + ) -> None: + """Build the diagnostic. + + ``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.default_locale = default_locale + self.extra_sources = list(extra_sources or []) + + def run(self, modules: list[ModuleBase]) -> list[Diagnostic]: + findings: list[Diagnostic] = [] + for mod in modules: + for namespace, locale_dir in mod.locale_dirs().items(): + findings.extend(self._check_namespace(mod.meta.name, namespace, Path(locale_dir))) + for reporter_name, namespace, locale_dir in self.extra_sources: + findings.extend(self._check_namespace(reporter_name, namespace, Path(locale_dir))) + return findings + + def _check_namespace( + self, module_name: str, namespace: str, locale_dir: Path + ) -> list[Diagnostic]: + findings: list[Diagnostic] = [] + per_locale_keys: dict[str, set[str]] = {} + + for locale in self.supported_locales: + path = locale_dir / f"{locale}.json" + if not path.is_file(): + findings.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM013", + message=(f"Missing locale file {locale}.json for namespace '{namespace}'"), + module_name=module_name, + file=str(path), + suggestion=f"Create {path} (even if empty: '{{}}')", + ) + ) + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("top-level JSON must be an object") + flat = flatten_messages(raw) + except (json.JSONDecodeError, ValueError) as exc: + findings.append( + Diagnostic( + level=DiagnosticLevel.ERROR, + code="SM016", + message=f"Invalid locale JSON in {path}: {exc}", + module_name=module_name, + file=str(path), + ) + ) + continue + per_locale_keys[locale] = set(flat.keys()) + + default_keys = per_locale_keys.get(self.default_locale, set()) + for locale, keys in per_locale_keys.items(): + if locale == self.default_locale: + continue + missing = default_keys - keys + extra = keys - default_keys + if missing: + findings.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM014", + message=( + f"Locale '{locale}' in namespace '{namespace}' is missing keys: " + f"{', '.join(sorted(missing))}" + ), + module_name=module_name, + ) + ) + if extra: + findings.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM015", + message=( + f"Locale '{locale}' in namespace '{namespace}' has keys not in " + f"default: {', '.join(sorted(extra))}" + ), + module_name=module_name, + ) + ) + return findings diff --git a/framework/core/simple_module_core/diagnostics/_runner.py b/framework/core/simple_module_core/diagnostics/_runner.py index 5f659a84..582b68fb 100644 --- a/framework/core/simple_module_core/diagnostics/_runner.py +++ b/framework/core/simple_module_core/diagnostics/_runner.py @@ -4,6 +4,7 @@ import logging import sys +from pathlib import Path from typing import TYPE_CHECKING from simple_module_core.diagnostics._migration import MigrationDiagnostics @@ -22,13 +23,30 @@ def run_diagnostics( migration_state: dict | None = None, module_tables: set[str] | None = None, migrated_tables: set[str] | None = None, + i18n_supported_locales: list[str] | None = None, + i18n_default_locale: str | None = None, + i18n_extra_sources: list[tuple[str, str, Path]] | None = None, ) -> list[Diagnostic]: """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``. """ diagnostics = ModuleDiagnostics().run(modules) + if i18n_supported_locales and i18n_default_locale: + from simple_module_core.diagnostics._i18n import I18nDiagnostics + + diagnostics.extend( + I18nDiagnostics( + supported_locales=i18n_supported_locales, + default_locale=i18n_default_locale, + extra_sources=i18n_extra_sources, + ).run(modules) + ) + if migration_state is not None: migration_diag = MigrationDiagnostics() diagnostics.extend( diff --git a/framework/core/simple_module_core/i18n.py b/framework/core/simple_module_core/i18n.py new file mode 100644 index 00000000..1faf15fa --- /dev/null +++ b/framework/core/simple_module_core/i18n.py @@ -0,0 +1,192 @@ +"""Internationalization registry and translator.""" + +from __future__ import annotations + +import json +import logging +from functools import lru_cache +from pathlib import Path +from typing import Any + +from babel import Locale + +logger = logging.getLogger(__name__) + +#: CLDR plural categories, in spec order. Used both for runtime resolution and +#: as the exhaustive suffix set when tools (e.g. the frontend-types emitter) +#: need to detect plural-variant keys. +PLURAL_CATEGORIES: tuple[str, ...] = ("zero", "one", "two", "few", "many", "other") + + +@lru_cache(maxsize=64) +def _plural_rule(locale: str): # type: ignore[no-untyped-def] + """Cached CLDR plural rule for a locale tag (e.g. 'en', 'ru', 'pt_BR').""" + return Locale.parse(locale).plural_form + + +def _plural_form(locale: str, count: float) -> str: + """Return CLDR plural category ('one', 'few', 'many', 'other', ...). + + Falls back to 'other' if the locale cannot be parsed by Babel. + """ + try: + rule = _plural_rule(locale) + except Exception: + return "other" + return rule(count) + + +def flatten_messages( + nested: dict[str, Any], + *, + prefix: str = "", +) -> dict[str, str]: + """Flatten a nested dict of string leaves to dotted keys. + + {"browse": {"title": "X"}} -> {"browse.title": "X"} + + Raises ValueError if any leaf is not a string. + """ + out: dict[str, str] = {} + for key, value in nested.items(): + composed = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + out.update(flatten_messages(value, prefix=composed)) + elif isinstance(value, str): + out[composed] = value + else: + raise ValueError( + f"Locale value at '{composed}' must be string or nested dict, " + f"got {type(value).__name__}" + ) + return out + + +class I18nRegistry: + """Merged view of all module locale JSON files, keyed by locale. + + Usage:: + + registry = I18nRegistry(default_locale="en", supported_locales=["en", "es"]) + registry.add_source("products", Path("modules/products/products/locales")) + registry.load() + registry.messages("en") # {"products.browse.title": "Products", ...} + """ + + 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._messages: dict[str, dict[str, str]] = {} + + 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 load(self) -> None: + """Read and flatten all registered JSON files. + + Missing .json files for declared supported_locales log a + warning but do not raise. Malformed JSON raises ValueError. + """ + self._messages = {locale: {} for locale in self.supported_locales} + + for namespace, locale_dir in self._sources: + for locale in self.supported_locales: + path = locale_dir / f"{locale}.json" + if not path.is_file(): + logger.warning( + "Missing locale file for namespace '%s': %s", + namespace, + path, + ) + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError(f"{path} must contain a JSON object at the top level") + flat = flatten_messages(raw, prefix=namespace) + self._messages[locale].update(flat) + + def available_locales(self) -> list[str]: + """Locales that have at least one loaded message.""" + return [locale for locale, msgs in self._messages.items() if msgs] + + def messages(self, locale: str) -> dict[str, str]: + """Flat dotted-key map for the given locale. Empty dict if unknown.""" + return dict(self._messages.get(locale, {})) + + +class _SafeFormatDict(dict): + """Dict that returns ``{key}`` for missing keys so str.format_map doesn't raise.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +class Translator: + """Request-scoped translator bound to a specific locale. + + Construct via:: + + Translator(registry, locale=request.state.locale, default_locale="en") + + Resolution order for :meth:`t`: + + 1. Look up key in ``locale``; if missing, fall back to ``default_locale``. + 2. If still missing, return the key itself (with a debug log). + 3. Interpolate ``{name}``-style placeholders using supplied kwargs. + Missing placeholders are left as ``{name}`` (not raised). + """ + + def __init__( + self, + registry: I18nRegistry, + locale: str, + default_locale: str, + ) -> None: + self._registry = registry + self.locale = locale + self.default_locale = default_locale + + def t(self, key: str, **params: Any) -> str: + """Translate ``key`` with optional interpolation and plural resolution. + + When ``count`` is in params, look up ``_`` using + Babel's CLDR plural rule for the active locale, falling back to + ``_other`` and finally ````. + """ + resolved_key = self._resolve_plural_key(key, params) + template = self._lookup(resolved_key) + if template is None and resolved_key != key: + template = self._lookup(key) + if template is None: + logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale) + return key + return template.format_map(_SafeFormatDict(params)) + + def _resolve_plural_key(self, key: str, params: dict[str, Any]) -> str: + count = params.get("count") + if count is None: + return key + form = _plural_form(self.locale, count) + # Prefer the exact form; fall back to _other if that form has no entry. + candidate = f"{key}_{form}" + if self._lookup(candidate) is not None: + return candidate + other = f"{key}_other" + if self._lookup(other) is not None: + return other + return key + + def _lookup(self, key: str) -> str | None: + msgs = self._registry.messages(self.locale) + if key in msgs: + return msgs[key] + if self.locale != self.default_locale: + default = self._registry.messages(self.default_locale) + if key in default: + return default[key] + return None diff --git a/framework/core/simple_module_core/module.py b/framework/core/simple_module_core/module.py index d01e8828..da88f952 100644 --- a/framework/core/simple_module_core/module.py +++ b/framework/core/simple_module_core/module.py @@ -152,6 +152,24 @@ def static_mounts(self) -> dict[str, Path]: """ return {} + def locale_dirs(self) -> dict[str, Path]: + """Return ``{namespace: directory}`` mapping for locale JSON files. + + Default returns an empty dict. Override to contribute a module's + locales:: + + return { + "products": importlib.resources.files(__package__) / "locales" + } + + The namespace becomes the key prefix in the merged i18n registry. + A file ``locales/en.json`` containing ``{"browse": {"title": "X"}}`` + becomes the key ``products.browse.title`` at runtime. + + Convention: use the module's lowercase name as the namespace. + """ + return {} + # ── Lifecycle ───────────────────────────────────────────── async def on_startup(self, app: FastAPI) -> None: diff --git a/framework/core/tests/test_i18n.py b/framework/core/tests/test_i18n.py new file mode 100644 index 00000000..253b9185 --- /dev/null +++ b/framework/core/tests/test_i18n.py @@ -0,0 +1,188 @@ +"""Tests for I18nRegistry, Translator, and plural resolution.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from simple_module_core.i18n import I18nRegistry, Translator, flatten_messages + + +class TestFlattenMessages: + def test_flattens_nested_dict_with_dotted_keys(self) -> None: + nested = {"browse": {"title": "Products", "count_one": "{count} product"}} + flat = flatten_messages(nested) + assert flat == { + "browse.title": "Products", + "browse.count_one": "{count} product", + } + + def test_flattens_deeply_nested(self) -> None: + nested = {"a": {"b": {"c": "hello"}}} + assert flatten_messages(nested) == {"a.b.c": "hello"} + + def test_rejects_non_string_leaves(self) -> None: + nested = {"count": 42} + with pytest.raises(ValueError, match="must be string"): + flatten_messages(nested) + + def test_rejects_list_values(self) -> None: + nested = {"items": ["a", "b"]} + with pytest.raises(ValueError, match="must be string"): + flatten_messages(nested) + + def test_empty_dict_returns_empty(self) -> None: + assert flatten_messages({}) == {} + + +class TestI18nRegistry: + def _write_locale(self, dir_: Path, lang: str, data: dict) -> None: + dir_.mkdir(parents=True, exist_ok=True) + (dir_ / f"{lang}.json").write_text(json.dumps(data)) + + def test_loads_single_namespace(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "products", "en", {"browse": {"title": "Products"}}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("products", tmp_path / "products") + reg.load() + assert reg.messages("en") == {"products.browse.title": "Products"} + + def test_merges_multiple_namespaces(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "p", "en", {"title": "Products"}) + self._write_locale(tmp_path / "a", "en", {"title": "Auth"}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("products", tmp_path / "p") + reg.add_source("auth", tmp_path / "a") + reg.load() + assert reg.messages("en") == {"products.title": "Products", "auth.title": "Auth"} + + def test_available_locales_reports_loaded(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "h", "en", {"k": "v"}) + self._write_locale(tmp_path / "h", "es", {"k": "v_es"}) + reg = I18nRegistry(default_locale="en", supported_locales=["en", "es", "de"]) + reg.add_source("host", tmp_path / "h") + reg.load() + assert sorted(reg.available_locales()) == ["en", "es"] + + def test_missing_locale_file_is_warning_not_error( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + self._write_locale(tmp_path / "h", "en", {"k": "v"}) + # No es.json + reg = I18nRegistry(default_locale="en", supported_locales=["en", "es"]) + reg.add_source("host", tmp_path / "h") + with caplog.at_level("WARNING"): + reg.load() + assert "missing locale file" in caplog.text.lower() + assert reg.messages("es") == {} + + def test_invalid_json_raises(self, tmp_path: Path) -> None: + d = tmp_path / "h" + d.mkdir() + (d / "en.json").write_text("{not valid json") + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("host", d) + with pytest.raises(ValueError, match="invalid JSON"): + reg.load() + + def test_messages_unknown_locale_returns_empty(self, tmp_path: Path) -> None: + self._write_locale(tmp_path / "h", "en", {"k": "v"}) + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg.add_source("host", tmp_path / "h") + reg.load() + assert reg.messages("fr") == {} + + +class TestTranslator: + def _registry_with(self, locale_data: dict[str, dict[str, str]]) -> I18nRegistry: + """Build a registry directly from in-memory data (bypasses filesystem).""" + reg = I18nRegistry(default_locale="en", supported_locales=list(locale_data.keys())) + reg._messages = locale_data + return reg + + def test_returns_string_for_known_key(self) -> None: + reg = self._registry_with({"en": {"hello": "Hello"}}) + t = Translator(reg, locale="en", default_locale="en") + assert t.t("hello") == "Hello" + + def test_interpolates_named_placeholders(self) -> None: + reg = self._registry_with({"en": {"greeting": "Hello, {name}"}}) + t = Translator(reg, locale="en", default_locale="en") + assert t.t("greeting", name="Ana") == "Hello, Ana" + + def test_missing_placeholder_keeps_brace_form(self) -> None: + reg = self._registry_with({"en": {"greeting": "Hello, {name}"}}) + t = Translator(reg, locale="en", default_locale="en") + # Param not supplied — value is the raw placeholder, not an exception. + assert t.t("greeting") == "Hello, {name}" + + def test_falls_back_to_default_locale(self) -> None: + reg = self._registry_with({"en": {"hello": "Hello"}, "es": {}}) + t = Translator(reg, locale="es", default_locale="en") + assert t.t("hello") == "Hello" + + def test_unknown_key_returns_key(self) -> None: + reg = self._registry_with({"en": {}}) + t = Translator(reg, locale="en", default_locale="en") + assert t.t("missing.key") == "missing.key" + + def test_prefers_requested_locale_over_default(self) -> None: + reg = self._registry_with({"en": {"hello": "Hello"}, "es": {"hello": "Hola"}}) + t = Translator(reg, locale="es", default_locale="en") + assert t.t("hello") == "Hola" + + +class TestTranslatorPlurals: + def test_english_one(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = { + "en": { + "items_one": "{count} item", + "items_other": "{count} items", + } + } + t = Translator(registry, locale="en", default_locale="en") + assert t.t("items", count=1) == "1 item" + + def test_english_other(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = {"en": {"items_one": "{count} item", "items_other": "{count} items"}} + t = Translator(registry, locale="en", default_locale="en") + assert t.t("items", count=5) == "5 items" + + def test_russian_few_many(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en", "ru"]) + registry._messages = { + "ru": { + "items_one": "{count} предмет", + "items_few": "{count} предмета", + "items_many": "{count} предметов", + "items_other": "{count} предмета", + } + } + t = Translator(registry, locale="ru", default_locale="en") + # Russian: 1 -> one, 2 -> few, 5 -> many + assert t.t("items", count=1) == "1 предмет" + assert t.t("items", count=2) == "2 предмета" + assert t.t("items", count=5) == "5 предметов" + + def test_no_count_no_plural_resolution(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = {"en": {"items": "Items"}} + t = Translator(registry, locale="en", default_locale="en") + # No 'count' param -> plain lookup. + assert t.t("items") == "Items" + + def test_falls_back_to_other_if_form_missing(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + # Only _other defined; 1 should still resolve via _other. + registry._messages = {"en": {"items_other": "{count} items"}} + t = Translator(registry, locale="en", default_locale="en") + assert t.t("items", count=1) == "1 items" + + def test_unknown_plural_key_returns_key(self) -> None: + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry._messages = {"en": {}} + t = Translator(registry, locale="en", default_locale="en") + assert t.t("missing", count=1) == "missing" diff --git a/framework/core/tests/test_i18n_diagnostics.py b/framework/core/tests/test_i18n_diagnostics.py new file mode 100644 index 00000000..09b6d323 --- /dev/null +++ b/framework/core/tests/test_i18n_diagnostics.py @@ -0,0 +1,101 @@ +"""Tests for I18nDiagnostics.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from simple_module_core import ModuleBase, ModuleMeta +from simple_module_core.diagnostics._i18n import I18nDiagnostics + + +class _FakeModule(ModuleBase): + def __init__(self, name: str, dirs: dict[str, Path]) -> None: + self.meta = ModuleMeta(name=name) + self._dirs = dirs + + def locale_dirs(self) -> dict[str, Path]: + return self._dirs + + +def _write(dir_: Path, lang: str, data: dict) -> None: + dir_.mkdir(parents=True, exist_ok=True) + (dir_ / f"{lang}.json").write_text(json.dumps(data)) + + +def test_reports_missing_locale_file(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1"}) + # no es.json + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM013" in codes + + +def test_reports_missing_keys_in_non_default_locale(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1", "b": "2"}) + _write(tmp_path / "p", "es", {"a": "1"}) # missing 'b' + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM014" in codes + assert any("b" in (f.message or "") for f in findings if f.code == "SM014") + + +def test_reports_extra_keys_in_non_default_locale(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1"}) + _write(tmp_path / "p", "es", {"a": "1", "extra": "x"}) + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM015" in codes + + +def test_reports_invalid_json(tmp_path: Path) -> None: + (tmp_path / "p").mkdir() + (tmp_path / "p" / "en.json").write_text("{ not json") + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en"], default_locale="en").run([mod]) + codes = {f.code for f in findings} + assert "SM016" in codes + + +def test_no_findings_when_keys_match(tmp_path: Path) -> None: + _write(tmp_path / "p", "en", {"a": "1"}) + _write(tmp_path / "p", "es", {"a": "1"}) + mod = _FakeModule("P", {"p": tmp_path / "p"}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + assert findings == [] + + +def test_module_without_locales_is_silently_skipped(tmp_path: Path) -> None: + mod = _FakeModule("P", {}) + findings = I18nDiagnostics(supported_locales=["en", "es"], default_locale="en").run([mod]) + assert findings == [] + + +def test_extra_sources_are_checked(tmp_path: Path) -> None: + """host/ui locale dirs (not owned by any ModuleBase) get the same checks.""" + _write(tmp_path / "host_locales", "en", {"a": "1"}) + # Missing es.json in host_locales -> should produce SM013 + findings = I18nDiagnostics( + supported_locales=["en", "es"], + default_locale="en", + extra_sources=[("host", "host", tmp_path / "host_locales")], + ).run([]) + codes = {f.code for f in findings} + assert "SM013" in codes + assert any(f.module_name == "host" for f in findings) + + +def test_extra_sources_detect_key_drift(tmp_path: Path) -> None: + """Key-parity checks apply to extra sources just like modules.""" + _write(tmp_path / "ui_locales", "en", {"a": "1", "b": "2"}) + _write(tmp_path / "ui_locales", "es", {"a": "1"}) # missing 'b' + findings = I18nDiagnostics( + supported_locales=["en", "es"], + default_locale="en", + extra_sources=[("packages/ui", "ui", tmp_path / "ui_locales")], + ).run([]) + codes = {f.code for f in findings} + assert "SM014" in codes diff --git a/framework/core/tests/test_module_base.py b/framework/core/tests/test_module_base.py index ef57d107..2fd89ce2 100644 --- a/framework/core/tests/test_module_base.py +++ b/framework/core/tests/test_module_base.py @@ -142,3 +142,23 @@ def static_mounts(self): mod = ModWithStatic() mounts = mod.static_mounts() assert mounts == {"/modules/with-static": assets} + + async def test_locale_dirs_default_empty(self): + """ModuleBase.locale_dirs() returns an empty dict by default.""" + mod = DummyModule() + assert mod.locale_dirs() == {} + + async def test_locale_dirs_override(self, tmp_path): + """A module can map namespaces to locale directories.""" + locales = tmp_path / "locales" + locales.mkdir() + + class ModWithLocales(ModuleBase): + meta = ModuleMeta(name="WithLocales") + + def locale_dirs(self): + return {"with_locales": locales} + + mod = ModWithLocales() + dirs = mod.locale_dirs() + assert dirs == {"with_locales": locales} diff --git a/framework/hosting/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py new file mode 100644 index 00000000..b3c67545 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_phase_helpers.py @@ -0,0 +1,142 @@ +"""Helpers extracted from ``app_builder.py`` — exception handlers, middleware +pipeline installation, static mounts, and the SM012 post-registration check. + +Kept private to the hosting package; ``app_builder.create_app`` is the only +intended caller. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from inertia import ( + InertiaVersionConflictException, + inertia_version_conflict_exception_handler, +) +from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel +from simple_module_core.exceptions import NotFoundError +from starlette.exceptions import HTTPException +from starlette.middleware.sessions import SessionMiddleware + +from simple_module_hosting._error_handlers import ( + http_exception_handler, + not_found_error_handler, + unhandled_exception_handler, +) +from simple_module_hosting.i18n_middleware import LocaleMiddleware +from simple_module_hosting.middleware import ( + CorrelationIdMiddleware, + InertiaLayoutDataMiddleware, + RequestLoggingMiddleware, + SecurityHeadersMiddleware, + TenantMiddleware, +) +from simple_module_hosting.settings import Settings + +if TYPE_CHECKING: + from simple_module_core.menu import MenuRegistry + from simple_module_core.permissions import PermissionRegistry + +logger = logging.getLogger(__name__) + + +def register_exception_handlers(app: FastAPI, modules: list) -> None: + """Install framework-level exception handlers, then per-module handlers.""" + app.add_exception_handler( + InertiaVersionConflictException, + inertia_version_conflict_exception_handler, # ty: ignore[invalid-argument-type] + ) + app.add_exception_handler(HTTPException, http_exception_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(NotFoundError, not_found_error_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(Exception, unhandled_exception_handler) + for mod in modules: + mod.register_exception_handlers(app) + + +def install_middleware( + app: FastAPI, + settings: Settings, + modules: list, + menu_registry: MenuRegistry, + perm_registry: PermissionRegistry, +) -> None: + """Install the full middleware pipeline. + + Order matters: last added = first executed. Execution order: + CorrelationId → RequestLogging → Security → Session + → [module] → (Tenant, if multi_tenant) → Locale → Inertia. + """ + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=menu_registry, + permission_registry=perm_registry, + ) + app.add_middleware( + LocaleMiddleware, + supported_locales=settings.i18n_supported_locales, + default_locale=settings.i18n_default_locale, + cookie_name=settings.i18n_cookie_name, + ) + if settings.multi_tenant: + app.add_middleware(TenantMiddleware, header=settings.tenant_header or None) + for mod in modules: + mod.register_middleware(app) + app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) + app.add_middleware(SecurityHeadersMiddleware) + app.add_middleware(RequestLoggingMiddleware) + app.add_middleware(CorrelationIdMiddleware) + + +def mount_module_static_dirs(app: FastAPI, modules: list) -> None: + """Mount each module's declared static directories. + + Modules typically expose ``/modules//static`` for pre-bundled + frontend assets shipped inside the wheel. + """ + for mod in modules: + for url_prefix, directory in mod.static_mounts().items(): + directory_path = Path(directory) + if not directory_path.is_dir(): + logger.warning( + "Module '%s' declared static mount %s -> %s but directory does not exist", + mod.meta.name, + url_prefix, + directory_path, + ) + continue + app.mount( + url_prefix, + StaticFiles(directory=directory_path), + name=f"static:{mod.meta.name}", + ) + + +def check_settings_registration(modules: list, added_keys: set[str]) -> None: + """SM012: warn if a module overrides register_settings but added nothing to app.state. + + Matches the convention key ``_settings`` exactly so a + module named ``cart`` doesn't shadow a module named ``cart_sales``. + """ + for mod in modules: + cls = type(mod) + if "register_settings" not in cls.__dict__: + continue + mod_prefix = mod.meta.name.lower() + expected_key = f"{mod_prefix}_settings" + if expected_key in added_keys: + continue + diag = Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM012", + message="register_settings() was overridden but added nothing to app.state", + module_name=mod.meta.name, + suggestion=( + f"Store your settings on app.state " + f"(e.g., app.state.{expected_key} = {mod.meta.name}Settings())" + ), + ) + logger.warning("%s", diag) diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 3ef38f6f..746f30ac 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -10,43 +10,26 @@ from fastapi import APIRouter, FastAPI from fastapi.staticfiles import StaticFiles -from inertia import ( - InertiaVersionConflictException, - inertia_version_conflict_exception_handler, -) -from simple_module_core.diagnostics import ( - Diagnostic, - DiagnosticLevel, - print_diagnostics, - run_diagnostics, -) +from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics from simple_module_core.discovery import discover_modules, topological_sort from simple_module_core.events import EventBus -from simple_module_core.exceptions import NotFoundError from simple_module_core.feature_flags import FeatureFlagRegistry from simple_module_core.health import HealthRegistry from simple_module_core.menu import MenuRegistry from simple_module_core.permissions import PermissionRegistry from simple_module_db.listeners import register_listeners from simple_module_db.session import init_db -from starlette.exceptions import HTTPException -from starlette.middleware.sessions import SessionMiddleware -from simple_module_hosting._error_handlers import ( - http_exception_handler, - not_found_error_handler, - unhandled_exception_handler, -) from simple_module_hosting._inertia_setup import setup_inertia from simple_module_hosting._migrations import check_migrations -from simple_module_hosting.health import router as health_router -from simple_module_hosting.middleware import ( - CorrelationIdMiddleware, - InertiaLayoutDataMiddleware, - RequestLoggingMiddleware, - SecurityHeadersMiddleware, - TenantMiddleware, +from simple_module_hosting._phase_helpers import ( + check_settings_registration, + install_middleware, + mount_module_static_dirs, + register_exception_handlers, ) +from simple_module_hosting.health import router as health_router +from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types from simple_module_hosting.settings import Settings logger = logging.getLogger(__name__) @@ -118,9 +101,18 @@ def create_app(settings: Settings | None = None) -> FastAPI: ", ".join(m.meta.name for m in modules), ) + # Build the i18n registry up front so diagnostics can validate key parity + # against host/ui locales, not just module-contributed ones. + i18n_registry, i18n_extra = build_i18n_registry(settings, modules, _PROJECT_ROOT) + # ── Phase 2: Run diagnostics (dev only) ──────────────── if settings.is_development: - diagnostics = run_diagnostics(modules) + diagnostics = run_diagnostics( + modules, + i18n_supported_locales=settings.i18n_supported_locales, + i18n_default_locale=settings.i18n_default_locale, + i18n_extra_sources=i18n_extra, + ) errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR] if diagnostics: print_diagnostics(diagnostics) @@ -138,6 +130,8 @@ def create_app(settings: Settings | None = None) -> FastAPI: except Exception: logger.exception("Failed to write module pages manifest — frontend may miss pages") + emit_frontend_types(i18n_registry, _PROJECT_ROOT) + # ── Phase 3: Create FastAPI app ──────────────────────── menu_registry = MenuRegistry() perm_registry = PermissionRegistry() @@ -170,6 +164,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.event_bus = event_bus app.state.health_registry = health_registry app.state.settings = settings + app.state.i18n_registry = i18n_registry + app.state.settings_default_locale = settings.i18n_default_locale + app.state.settings_supported_locales = settings.i18n_supported_locales + app.state.settings_cookie_name = settings.i18n_cookie_name # ── Phase 4: Module settings ─────────────────────────── state_before = set(vars(app.state)) @@ -179,7 +177,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # SM012: warn if register_settings was overridden but added nothing if settings.is_development: state_after = set(vars(app.state)) - _check_settings_registration(modules, state_after - state_before) + check_settings_registration(modules, state_after - state_before) # ── Phase 5: Module registrations ────────────────────── for mod in modules: @@ -204,34 +202,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # ── Phase 7: Inertia + exception handlers ────────────── setup_inertia(app, settings, modules, _PROJECT_ROOT) - - app.add_exception_handler( - InertiaVersionConflictException, - inertia_version_conflict_exception_handler, # ty: ignore[invalid-argument-type] - ) - app.add_exception_handler(HTTPException, http_exception_handler) # ty: ignore[invalid-argument-type] - app.add_exception_handler(NotFoundError, not_found_error_handler) # ty: ignore[invalid-argument-type] - app.add_exception_handler(Exception, unhandled_exception_handler) - for mod in modules: - mod.register_exception_handlers(app) + register_exception_handlers(app, modules) # ── Phase 8: Middleware pipeline ─────────────────────── - # Order matters: last added = first executed. - # Execution: CorrelationId → RequestLogging → Security → Session - # → [module] → (Tenant, if multi_tenant) → Inertia - app.add_middleware( - InertiaLayoutDataMiddleware, - menu_registry=menu_registry, - permission_registry=perm_registry, - ) - if settings.multi_tenant: - app.add_middleware(TenantMiddleware, header=settings.tenant_header or None) - for mod in modules: - mod.register_middleware(app) - app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) - app.add_middleware(SecurityHeadersMiddleware) - app.add_middleware(RequestLoggingMiddleware) - app.add_middleware(CorrelationIdMiddleware) + install_middleware(app, settings, modules, menu_registry, perm_registry) # ── Phase 9: Routes, health, static files ────────────── for mod in modules: @@ -243,51 +217,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: if static_dir.is_dir(): app.mount("/static", StaticFiles(directory=static_dir), name="static") - # Each module may expose directories at its own URL prefix — typically - # /modules//static for pre-bundled frontend assets shipped inside - # the module wheel. - for mod in modules: - for url_prefix, directory in mod.static_mounts().items(): - directory_path = Path(directory) - if not directory_path.is_dir(): - logger.warning( - "Module '%s' declared static mount %s -> %s but directory does not exist", - mod.meta.name, - url_prefix, - directory_path, - ) - continue - app.mount( - url_prefix, - StaticFiles(directory=directory_path), - name=f"static:{mod.meta.name}", - ) + mount_module_static_dirs(app, modules) return app - - -def _check_settings_registration(modules: list, added_keys: set[str]) -> None: - """SM012: warn if a module overrides register_settings but added nothing to app.state. - - Matches the convention key ``_settings`` exactly so a - module named ``cart`` doesn't shadow a module named ``cart_sales``. - """ - for mod in modules: - cls = type(mod) - if "register_settings" not in cls.__dict__: - continue - mod_prefix = mod.meta.name.lower() - expected_key = f"{mod_prefix}_settings" - if expected_key in added_keys: - continue - diag = Diagnostic( - level=DiagnosticLevel.WARNING, - code="SM012", - message="register_settings() was overridden but added nothing to app.state", - module_name=mod.meta.name, - suggestion=( - f"Store your settings on app.state " - f"(e.g., app.state.{expected_key} = {mod.meta.name}Settings())" - ), - ) - logger.warning("%s", diag) diff --git a/framework/hosting/simple_module_hosting/i18n_deps.py b/framework/hosting/simple_module_hosting/i18n_deps.py new file mode 100644 index 00000000..4dbfb152 --- /dev/null +++ b/framework/hosting/simple_module_hosting/i18n_deps.py @@ -0,0 +1,26 @@ +"""FastAPI dependency for request-scoped Translator resolution.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request +from simple_module_core.i18n import Translator + + +async def get_translator(request: Request) -> Translator: + """Resolve a Translator bound to ``request.state.locale``. + + Reads the registry from ``request.app.state.i18n_registry`` and the + default locale from ``request.app.state.settings_default_locale`` + (populated by create_app). + + ``request.state.locale`` is populated by LocaleMiddleware. + """ + registry = request.app.state.i18n_registry + default_locale = request.app.state.settings_default_locale + locale = getattr(request.state, "locale", default_locale) + return Translator(registry, locale=locale, default_locale=default_locale) + + +TranslatorDep = Annotated[Translator, Depends(get_translator)] diff --git a/framework/hosting/simple_module_hosting/i18n_manifest.py b/framework/hosting/simple_module_hosting/i18n_manifest.py new file mode 100644 index 00000000..49b08f12 --- /dev/null +++ b/framework/hosting/simple_module_hosting/i18n_manifest.py @@ -0,0 +1,201 @@ +"""Emit generated-resources.ts + keys.generated.ts for the frontend. + +Both files are consumed by ``@simple-module/i18n``: + +* ``generated-resources.ts`` — flat empty-string keys, fed into i18next's + ``CustomTypeOptions['resources']`` so ``t('foo.bar')`` narrows to the + accepted key union. + +* ``keys.generated.ts`` — a nested ``keys`` constant whose leaves are the + full dotted key strings. Consumers use ``t(keys.foo.bar)`` for typed + call-sites. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from simple_module_core import ModuleBase +from simple_module_core.i18n import PLURAL_CATEGORIES, I18nRegistry + +from simple_module_hosting.manifest import _write_if_changed +from simple_module_hosting.settings import Settings + +logger = logging.getLogger(__name__) + + +def build_i18n_registry( + settings: Settings, + modules: list[ModuleBase], + project_root: Path, +) -> tuple[I18nRegistry, list[tuple[str, str, Path]]]: + """Construct the i18n registry from module + host + UI sources. + + Returns ``(registry, extra_sources)`` where ``extra_sources`` is the list + of ``(reporter_name, namespace, dir)`` triples the diagnostic runner + needs to validate non-module locale directories. + """ + registry = I18nRegistry( + default_locale=settings.i18n_default_locale, + supported_locales=settings.i18n_supported_locales, + ) + extra_sources: list[tuple[str, str, Path]] = [] + + for mod in modules: + for namespace, locale_dir in mod.locale_dirs().items(): + registry.add_source(namespace, locale_dir) + + host_locales = project_root / "host" / "locales" + if host_locales.is_dir(): + registry.add_source("host", host_locales) + extra_sources.append(("host", "host", host_locales)) + + ui_locales = project_root / "packages" / "ui" / "locales" + if ui_locales.is_dir(): + registry.add_source("ui", ui_locales) + extra_sources.append(("packages/ui", "ui", ui_locales)) + + registry.load() + return registry, extra_sources + + +def emit_frontend_types(registry: I18nRegistry, project_root: Path) -> None: + """Write the TS augmentation files into @simple-module/i18n if present. + + Logs but does not raise on failure — stale types are preferable to a + broken boot. Dev-loop only; callers should gate on ``is_development``. + """ + try: + pkg_src = project_root / "packages" / "i18n" / "src" + if pkg_src.is_dir(): + write_generated_resources(registry, pkg_src) + except Exception: + logger.exception("Failed to write generated-resources.ts — frontend types will be stale") + + +_RESOURCES_HEADER = """\ +// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. +// Regenerate by booting the host in development mode. +""" + +_KEYS_HEADER = """\ +// AUTO-GENERATED by simple_module_hosting.i18n_manifest — do not edit by hand. +// Regenerate by booting the host in development mode. +""" + +_PLURAL_SUFFIXES: tuple[str, ...] = tuple(f"_{c}" for c in PLURAL_CATEGORIES) + + +def write_generated_resources(registry: I18nRegistry, output_dir: Path) -> Path: + """Write both ``generated-resources.ts`` and ``keys.generated.ts``. + + Returns the path of the resources file. Both files skip the disk write + when content matches what's already on disk (to avoid bumping mtimes + that would trigger spurious Vite HMR). + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + messages = registry.messages(registry.default_locale) + keys = sorted(messages.keys()) + + resources_path = output_dir / "generated-resources.ts" + if _write_if_changed(resources_path, _render_resources(keys)): + logger.info("Wrote %s (%d keys)", resources_path.name, len(keys)) + + keys_path = output_dir / "keys.generated.ts" + if _write_if_changed(keys_path, _render_keys(keys)): + logger.info("Wrote %s", keys_path.name) + + return resources_path + + +def _render_resources(keys: list[str]) -> str: + lines = [_RESOURCES_HEADER, "", "export default {", " translation: {"] + for key in keys: + lines.append(f" '{key}': '',") + lines.append(" },") + lines.append("} as const;") + lines.append("") + return "\n".join(lines) + + +def _render_keys(keys: list[str]) -> str: + """Render the nested ``keys`` constant tree.""" + tree = _build_key_tree(keys) + return f"{_KEYS_HEADER}\nexport const keys = {_serialize(tree, indent=0)} as const;\n" + + +def _build_key_tree(flat_keys: list[str]) -> dict[str, Any]: + """Build a nested dict from a list of dotted keys. + + Leaves are the full dotted key string. Plural stems (e.g. ``foo.items`` + when ``foo.items_one``/``foo.items_other`` exist) are added as virtual + leaves so callers can pass them directly to ``t(key, {count})``. + """ + tree: dict[str, Any] = {} + stems: set[str] = set() + + for flat_key in flat_keys: + for suffix in _PLURAL_SUFFIXES: + if flat_key.endswith(suffix): + stems.add(flat_key[: -len(suffix)]) + break + _insert(tree, flat_key.split("."), flat_key) + + existing = set(flat_keys) + for stem in stems: + if stem in existing: + continue + _insert(tree, stem.split("."), stem) + + return tree + + +def _insert(tree: dict[str, Any], path: list[str], value: str) -> None: + """Insert ``value`` into ``tree`` at ``path``. + + Existing leaves win over later inserts — real keys cannot be shadowed + by virtual plural stems, and a path segment that collides with an + existing leaf causes the insertion to be silently aborted. + """ + cursor: Any = tree + for segment in path[:-1]: + existing = cursor.get(segment) + if isinstance(existing, dict): + cursor = existing + elif existing is None: + new_dict: dict[str, Any] = {} + cursor[segment] = new_dict + cursor = new_dict + else: + return + leaf_key = path[-1] + if leaf_key not in cursor: + cursor[leaf_key] = value + + +def _serialize(node: Any, *, indent: int) -> str: + """Emit a dict-of-dicts tree as pretty-printed TypeScript object literal.""" + if isinstance(node, str): + return json.dumps(node) + if not isinstance(node, dict) or not node: + return "{}" + pad = " " * indent + inner_pad = " " * (indent + 1) + lines = ["{"] + for k in sorted(node.keys()): + key_repr = k if _is_valid_js_identifier(k) else json.dumps(k) + lines.append(f"{inner_pad}{key_repr}: {_serialize(node[k], indent=indent + 1)},") + lines.append(f"{pad}}}") + return "\n".join(lines) + + +def _is_valid_js_identifier(name: str) -> bool: + """True if ``name`` can be an unquoted object key in JS (ASCII subset).""" + if not name or not (name[0].isalpha() or name[0] in "_$"): + return False + return all(c.isalnum() or c in "_$" for c in name) diff --git a/framework/hosting/simple_module_hosting/i18n_middleware.py b/framework/hosting/simple_module_hosting/i18n_middleware.py new file mode 100644 index 00000000..d2913893 --- /dev/null +++ b/framework/hosting/simple_module_hosting/i18n_middleware.py @@ -0,0 +1,95 @@ +"""LocaleMiddleware — resolve active locale from cookie / Accept-Language / default.""" + +from __future__ import annotations + +from starlette.datastructures import Headers +from starlette.requests import Request +from starlette.types import ASGIApp, Receive, Scope, Send + + +class LocaleMiddleware: + """Set ``request.state.locale`` based on cookie, Accept-Language, and default. + + Resolution order: + + 1. Cookie named ``cookie_name``, validated against ``supported_locales``. + 2. ``Accept-Language`` header, negotiated against supported_locales via + longest-prefix match (``es-MX`` matches supported ``es``). + 3. ``default_locale``. + + Runs as a pure ASGI middleware (no BaseHTTPMiddleware) to match the rest + of the framework's middleware stack. + """ + + def __init__( + self, + app: ASGIApp, + *, + supported_locales: list[str], + default_locale: str, + cookie_name: str = "locale", + ) -> None: + self.app = app + self.supported = list(supported_locales) + self.default_locale = default_locale + self.cookie_name = cookie_name + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope) + locale = self._resolve(request) + request.state.locale = locale + await self.app(scope, receive, send) + + def _resolve(self, request: Request) -> str: + # 1. Cookie. + cookie = request.cookies.get(self.cookie_name) + if cookie and cookie in self.supported: + return cookie + + # 2. Accept-Language. + accept = Headers(scope=request.scope).get("accept-language") + if accept: + matched = self._negotiate(accept) + if matched: + return matched + + # 3. Default. + return self.default_locale + + def _negotiate(self, accept_language: str) -> str | None: + """Parse Accept-Language and return the highest-q supported locale. + + Matches either exact tag or primary prefix (``es-MX`` -> ``es``). + """ + # Hard cap to blunt adversarial Accept-Language: a,a,a,... spam. + # Real browsers send <10 tags; 20 is comfortably above that. + parts = accept_language.split(",", 20) + candidates: list[tuple[float, str]] = [] + for part in parts[:20]: + part = part.strip() + if not part: + continue + tag, _, q_part = part.partition(";") + tag = tag.strip().lower() + q_part = q_part.strip().lower() + try: + q = float(q_part.split("=", 1)[1]) if q_part.startswith("q=") else 1.0 + except ValueError: + q = 1.0 + candidates.append((q, tag)) + + # Sort by q descending, stable. + candidates.sort(key=lambda pair: -pair[0]) + + supported_lower = {loc.lower(): loc for loc in self.supported} + for _, tag in candidates: + if tag in supported_lower: + return supported_lower[tag] + primary = tag.split("-", 1)[0] + if primary in supported_lower: + return supported_lower[primary] + return None diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index e31f383b..409419bb 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -26,6 +26,7 @@ from simple_module_core.permissions import PermissionRegistry _request_logger = logging.getLogger("simple_module.request") +logger = logging.getLogger(__name__) # Paths that produce noisy, low-value log entries _QUIET_PREFIXES = ("/health", "/static/") @@ -229,6 +230,30 @@ 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 [] + registry = getattr(request.app.state, "i18n_registry", None) + locale = getattr(request.state, "locale", None) + if registry is not None and locale is not None: + # Use available_locales() (locales with actual loaded messages) — + # NOT the configured supported_locales list. Offering a locale that + # has no JSON files would render a mostly-empty UI when selected. + i18n_block = { + "locale": locale, + "supportedLocales": registry.available_locales(), + "messages": registry.messages(locale), + } + else: + logger.warning( + "InertiaLayoutDataMiddleware: i18n not fully wired " + "(registry_present=%s, locale_present=%s); serving empty messages", + registry is not None, + locale is not None, + ) + i18n_block = { + "locale": "en", + "supportedLocales": ["en"], + "messages": {}, + } + shared: dict = { "auth": { "user": ( @@ -249,6 +274,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: roles=roles, ), "csrf_token": secrets.token_urlsafe(32) if is_authenticated else "", + "i18n": i18n_block, } request.state.inertia_shared = shared diff --git a/framework/hosting/simple_module_hosting/settings.py b/framework/hosting/simple_module_hosting/settings.py index 9619c0ff..51a937ff 100644 --- a/framework/hosting/simple_module_hosting/settings.py +++ b/framework/hosting/simple_module_hosting/settings.py @@ -2,6 +2,7 @@ from typing import Literal +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -48,6 +49,28 @@ class Settings(BaseSettings): # the auth token only. tenant_header: str = "" + # Internationalization + i18n_default_locale: str = "en" + """Locale used when no cookie, Accept-Language, or supported locale match.""" + + i18n_supported_locales: list[str] = ["en"] + """Locales the host will serve. Must include i18n_default_locale. + + Set via env as JSON-style list, e.g. ``SM_I18N_SUPPORTED_LOCALES='["en","es"]'``. + """ + + i18n_cookie_name: str = "locale" + """Name of the cookie that overrides browser Accept-Language.""" + @property def is_development(self) -> bool: return self.environment == "development" + + @model_validator(mode="after") + def _check_default_locale_supported(self) -> "Settings": + if self.i18n_default_locale not in self.i18n_supported_locales: + raise ValueError( + f"i18n_default_locale '{self.i18n_default_locale}' is not in " + f"i18n_supported_locales {self.i18n_supported_locales}" + ) + return self diff --git a/framework/hosting/tests/test_i18n_manifest.py b/framework/hosting/tests/test_i18n_manifest.py new file mode 100644 index 00000000..4b44734d --- /dev/null +++ b/framework/hosting/tests/test_i18n_manifest.py @@ -0,0 +1,116 @@ +"""Tests for generated-resources.ts emission.""" + +from __future__ import annotations + +from pathlib import Path + +from simple_module_core.i18n import I18nRegistry +from simple_module_hosting.i18n_manifest import write_generated_resources + + +def test_writes_file_with_flat_keys(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = { + "en": { + "host.landing.title": "Hello", + "products.browse.title": "Products", + } + } + out = write_generated_resources(reg, tmp_path) + text = out.read_text() + assert "'host.landing.title': ''" in text + assert "'products.browse.title': ''" in text + assert "AUTO-GENERATED" in text + assert "export default" in text + + +def test_keys_are_sorted(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = {"en": {"z.a": "", "a.z": "", "m.m": ""}} + out = write_generated_resources(reg, tmp_path) + text = out.read_text() + a_idx = text.index("'a.z'") + m_idx = text.index("'m.m'") + z_idx = text.index("'z.a'") + assert a_idx < m_idx < z_idx + + +def test_only_writes_when_changed(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = {"en": {"k": "v"}} + out = write_generated_resources(reg, tmp_path) + first_mtime = out.stat().st_mtime_ns + # Second call with identical content should not re-touch the file. + write_generated_resources(reg, tmp_path) + assert out.stat().st_mtime_ns == first_mtime + + +def test_keys_file_is_emitted_alongside_resources(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = {"en": {"products.browse.title": "Products"}} + write_generated_resources(reg, tmp_path) + keys_file = tmp_path / "keys.generated.ts" + assert keys_file.is_file() + text = keys_file.read_text() + assert "export const keys" in text + assert "as const" in text + + +def test_keys_tree_is_nested(tmp_path: Path) -> None: + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = { + "en": { + "products.browse.title": "Products", + "products.browse.description": "Manage", + "auth.errors.not_authenticated": "No", + } + } + write_generated_resources(reg, tmp_path) + text = (tmp_path / "keys.generated.ts").read_text() + # Nested structure: keys = { auth: { errors: { not_authenticated: ... } }, products: { ... } } + assert "auth:" in text + assert "errors:" in text + assert 'not_authenticated: "auth.errors.not_authenticated"' in text + assert 'title: "products.browse.title"' in text + + +def test_keys_tree_adds_plural_stems(tmp_path: Path) -> None: + """Plural variants get a virtual stem so t(keys.foo.count, {count}) works.""" + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = { + "en": { + "products.browse.count_one": "{count} product", + "products.browse.count_other": "{count} products", + } + } + write_generated_resources(reg, tmp_path) + text = (tmp_path / "keys.generated.ts").read_text() + # Both the concrete variants AND the virtual stem must be emitted. + assert 'count_one: "products.browse.count_one"' in text + assert 'count_other: "products.browse.count_other"' in text + assert 'count: "products.browse.count"' in text + + +def test_keys_tree_quotes_non_identifier_segments(tmp_path: Path) -> None: + """Segments that aren't valid JS identifiers are quoted as string keys.""" + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = {"en": {"ui.switcher.a-b": "X"}} # hyphen in leaf + write_generated_resources(reg, tmp_path) + text = (tmp_path / "keys.generated.ts").read_text() + assert '"a-b":' in text + + +def test_keys_tree_does_not_overwrite_real_key_with_stem(tmp_path: Path) -> None: + """If a real key already matches a plural stem, the real value wins.""" + reg = I18nRegistry(default_locale="en", supported_locales=["en"]) + reg._messages = { + "en": { + "products.browse.count": "Special", # real key named 'count' + "products.browse.count_one": "one", + "products.browse.count_other": "other", + } + } + write_generated_resources(reg, tmp_path) + text = (tmp_path / "keys.generated.ts").read_text() + # The real key retains its value; the virtual stem is skipped. + assert 'count: "products.browse.count"' in text diff --git a/framework/hosting/tests/test_inertia_i18n_shared_props.py b/framework/hosting/tests/test_inertia_i18n_shared_props.py new file mode 100644 index 00000000..b32be4f6 --- /dev/null +++ b/framework/hosting/tests/test_inertia_i18n_shared_props.py @@ -0,0 +1,95 @@ +"""Verify InertiaLayoutDataMiddleware injects an i18n block into shared props.""" + +from __future__ import annotations + +from fastapi import FastAPI +from simple_module_core.i18n import I18nRegistry +from simple_module_core.menu import MenuRegistry +from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting.i18n_middleware import LocaleMiddleware +from simple_module_hosting.middleware import InertiaLayoutDataMiddleware +from starlette.middleware.sessions import SessionMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.testclient import TestClient + + +def _build_app() -> FastAPI: + reg = I18nRegistry(default_locale="en", supported_locales=["en", "es"]) + reg._messages = { + "en": {"hello": "Hello"}, + "es": {"hello": "Hola"}, + } + + app = FastAPI() + app.state.i18n_registry = reg + + @app.get("/shared") + def shared(request: Request) -> JSONResponse: + return JSONResponse(request.state.inertia_shared) + + # Stack order (last added = outermost = runs first): + # LocaleMiddleware (outer) -> InertiaLayoutDataMiddleware (inner) + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=MenuRegistry(), + permission_registry=PermissionRegistry(), + ) + app.add_middleware( + LocaleMiddleware, + supported_locales=["en", "es"], + default_locale="en", + ) + app.add_middleware(SessionMiddleware, secret_key="test-secret") + return app + + +def test_inertia_shared_props_include_i18n_block_for_default_locale() -> None: + client = TestClient(_build_app()) + resp = client.get("/shared") + body = resp.json() + assert "i18n" in body + assert body["i18n"]["locale"] == "en" + assert body["i18n"]["supportedLocales"] == ["en", "es"] + assert body["i18n"]["messages"] == {"hello": "Hello"} + + +def test_inertia_shared_props_reflect_cookie_locale() -> None: + client = TestClient(_build_app()) + resp = client.get("/shared", cookies={"locale": "es"}) + body = resp.json() + assert body["i18n"]["locale"] == "es" + assert body["i18n"]["messages"] == {"hello": "Hola"} + + +def test_inertia_shared_props_fallback_when_registry_missing( + caplog, +) -> None: + """When app.state.i18n_registry is absent, the middleware logs a warning and + falls back to a minimal i18n block so tests / misconfigured apps don't crash. + """ + import logging + + app = FastAPI() + # Intentionally do NOT set app.state.i18n_registry. + + @app.get("/shared") + def shared(request: Request) -> JSONResponse: + return JSONResponse(request.state.inertia_shared) + + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=MenuRegistry(), + permission_registry=PermissionRegistry(), + ) + # No LocaleMiddleware either — request.state.locale also absent. + app.add_middleware(SessionMiddleware, secret_key="test-secret") + + with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"): + client = TestClient(app) + resp = client.get("/shared") + + body = resp.json() + assert body["i18n"] == {"locale": "en", "supportedLocales": ["en"], "messages": {}} + # Fallback must be loud enough to surface misconfigurations: + assert any("not fully wired" in rec.message for rec in caplog.records) diff --git a/framework/hosting/tests/test_locale_middleware.py b/framework/hosting/tests/test_locale_middleware.py new file mode 100644 index 00000000..857dceed --- /dev/null +++ b/framework/hosting/tests/test_locale_middleware.py @@ -0,0 +1,103 @@ +"""Tests for LocaleMiddleware request-state population.""" + +from __future__ import annotations + +from simple_module_hosting.i18n_middleware import LocaleMiddleware +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.testclient import TestClient + + +def _build_app(supported: list[str], default: str, cookie_name: str = "locale") -> Starlette: + async def endpoint(request: Request) -> JSONResponse: + return JSONResponse({"locale": request.state.locale}) + + app = Starlette(routes=[Route("/", endpoint)]) + app.add_middleware( + LocaleMiddleware, + supported_locales=supported, + default_locale=default, + cookie_name=cookie_name, + ) + return app + + +def test_uses_cookie_when_present_and_supported() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", cookies={"locale": "es"}) + assert resp.json() == {"locale": "es"} + + +def test_ignores_cookie_when_locale_not_supported() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", cookies={"locale": "de"}) + # Falls through to Accept-Language, then to default (en). + assert resp.json() == {"locale": "en"} + + +def test_uses_accept_language_when_no_cookie() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", headers={"Accept-Language": "es,en;q=0.8"}) + assert resp.json() == {"locale": "es"} + + +def test_prefix_match_accept_language() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + # "es-MX" should match supported "es" via prefix. + resp = client.get("/", headers={"Accept-Language": "es-MX"}) + assert resp.json() == {"locale": "es"} + + +def test_falls_back_to_default_when_nothing_matches() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get("/", headers={"Accept-Language": "de,fr;q=0.5"}) + assert resp.json() == {"locale": "en"} + + +def test_cookie_takes_precedence_over_accept_language() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + resp = client.get( + "/", + cookies={"locale": "es"}, + headers={"Accept-Language": "de"}, + ) + assert resp.json() == {"locale": "es"} + + +def test_custom_cookie_name() -> None: + app = _build_app(["en", "es"], "en", cookie_name="lang") + client = TestClient(app) + resp = client.get("/", cookies={"lang": "es"}) + assert resp.json() == {"locale": "es"} + + +def test_ows_around_semicolon_does_not_drop_q_value() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + # "es ; q=0.1" should be interpreted with q=0.1; "en" default wins via + # higher q via the _falls_back test already. Here verify a simpler case: + # "de ; q=1.0, es ; q=0.5" — de unsupported, es supported -> es. + resp = client.get( + "/", + headers={"Accept-Language": "de ; q=1.0, es ; q=0.5"}, + ) + assert resp.json() == {"locale": "es"} + + +def test_lower_q_supported_beats_higher_q_unsupported() -> None: + app = _build_app(["en", "es"], "en") + client = TestClient(app) + # de has higher q but is unsupported; es should win. + resp = client.get( + "/", + headers={"Accept-Language": "de;q=1.0,es;q=0.1"}, + ) + assert resp.json() == {"locale": "es"} diff --git a/framework/hosting/tests/test_settings_i18n.py b/framework/hosting/tests/test_settings_i18n.py new file mode 100644 index 00000000..1f2a2161 --- /dev/null +++ b/framework/hosting/tests/test_settings_i18n.py @@ -0,0 +1,31 @@ +"""Tests for i18n-related Settings validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from simple_module_hosting.settings import Settings + + +def test_default_locale_must_be_in_supported_list() -> None: + with pytest.raises(ValueError, match="i18n_default_locale"): + Settings(i18n_default_locale="fr", i18n_supported_locales=["en", "es"]) + + +def test_default_locale_in_supported_list_passes() -> None: + s = Settings(i18n_default_locale="es", i18n_supported_locales=["en", "es"]) + assert s.i18n_default_locale == "es" + + +def test_default_settings_are_valid(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # Built-in defaults must pass the validator (en in [en]). Isolate from any + # local .env and SM_* env vars so the test reflects pure built-in defaults. + monkeypatch.chdir(tmp_path) + import os + + for var in [k for k in os.environ if k.startswith("SM_I18N_")]: + monkeypatch.delenv(var, raising=False) + s = Settings() + assert s.i18n_default_locale == "en" + assert s.i18n_supported_locales == ["en"] diff --git a/framework/hosting/tests/test_translator_dep.py b/framework/hosting/tests/test_translator_dep.py new file mode 100644 index 00000000..e1413ec0 --- /dev/null +++ b/framework/hosting/tests/test_translator_dep.py @@ -0,0 +1,55 @@ +"""Tests for TranslatorDep end-to-end via a minimal app.""" + +from __future__ import annotations + +from fastapi import FastAPI +from simple_module_core.i18n import I18nRegistry, Translator +from simple_module_hosting.i18n_deps import TranslatorDep +from simple_module_hosting.i18n_middleware import LocaleMiddleware +from starlette.testclient import TestClient + + +def _build_app() -> FastAPI: + reg = I18nRegistry(default_locale="en", supported_locales=["en", "es"]) + reg._messages = { + "en": {"hello": "Hello, {name}"}, + "es": {"hello": "Hola, {name}"}, + } + + app = FastAPI() + app.state.i18n_registry = reg + app.state.settings_default_locale = "en" + + @app.get("/hi") + def hi(t: TranslatorDep, name: str = "friend") -> dict[str, str]: + return {"greeting": t.t("hello", name=name), "locale": t.locale} + + app.add_middleware( + LocaleMiddleware, + supported_locales=["en", "es"], + default_locale="en", + ) + return app + + +def test_translator_dep_uses_request_locale() -> None: + client = TestClient(_build_app()) + resp = client.get("/hi?name=Ana", cookies={"locale": "es"}) + assert resp.json() == {"greeting": "Hola, Ana", "locale": "es"} + + +def test_translator_dep_falls_back_to_default_locale() -> None: + client = TestClient(_build_app()) + resp = client.get("/hi?name=Ana") # no cookie, no Accept-Language + assert resp.json() == {"greeting": "Hello, Ana", "locale": "en"} + + +def test_translator_dep_returned_is_translator_instance() -> None: + app = _build_app() + + @app.get("/type") + def type_check(t: TranslatorDep) -> dict[str, bool]: + return {"is_translator": isinstance(t, Translator)} + + client = TestClient(app) + assert client.get("/type").json() == {"is_translator": True} diff --git a/host/client_app/app.tsx b/host/client_app/app.tsx index 95d50de7..7c0a2d8f 100644 --- a/host/client_app/app.tsx +++ b/host/client_app/app.tsx @@ -2,6 +2,7 @@ import { createInertiaApp, router } from '@inertiajs/react'; import { ErrorBoundary } from '@simple-module/ui/components/ErrorBoundary'; import { useEffect, useRef } from 'react'; import { createRoot } from 'react-dom/client'; +import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n'; import { resolvePage } from './pages'; createInertiaApp({ @@ -10,12 +11,18 @@ createInertiaApp({ return page; }, setup({ el, App, props }) { + bootI18nFromInitialPage(props.initialPage.props); + function Root() { const boundaryRef = useRef(null); useEffect(() => { - // Reset the boundary on navigation so the next page can render. - return router.on('navigate', () => boundaryRef.current?.reset()); + const stopReset = router.on('navigate', () => boundaryRef.current?.reset()); + const stopI18n = subscribeI18nToNavigation(); + return () => { + stopReset(); + stopI18n(); + }; }, []); return ( diff --git a/host/client_app/i18n.ts b/host/client_app/i18n.ts new file mode 100644 index 00000000..0a976dff --- /dev/null +++ b/host/client_app/i18n.ts @@ -0,0 +1,39 @@ +/** + * Initial wiring for @simple-module/i18n inside the Inertia app. + * + * Reads {locale, messages} from Inertia shared props and calls + * configureI18n on boot; on every successful navigation, checks whether + * the active locale changed and updates the i18next resources. + */ + +import type { PageProps } from '@inertiajs/core'; +import { router } from '@inertiajs/react'; +import { configureI18n, updateI18n } from '@simple-module/i18n'; + +interface I18nSharedProps { + locale: string; + supportedLocales: string[]; + messages: Record; +} + +export function bootI18nFromInitialPage(props: PageProps): void { + const i18n = (props as unknown as { i18n?: I18nSharedProps }).i18n; + if (!i18n) { + configureI18n({ locale: 'en', messages: {} }); + return; + } + configureI18n({ locale: i18n.locale, messages: i18n.messages }); +} + +let activeLocale: string | null = null; + +export function subscribeI18nToNavigation(): () => void { + return router.on('success', (event) => { + const i18n = (event.detail.page.props as unknown as { i18n?: I18nSharedProps }).i18n; + if (!i18n) return; + if (i18n.locale !== activeLocale) { + updateI18n({ locale: i18n.locale, messages: i18n.messages }); + activeLocale = i18n.locale; + } + }); +} diff --git a/host/client_app/package.json b/host/client_app/package.json index 1ed94937..16e59c10 100644 --- a/host/client_app/package.json +++ b/host/client_app/package.json @@ -12,11 +12,13 @@ "@hookform/resolvers": "^5.2.2", "@inertiajs/react": "^2.0.0", "@radix-ui/react-slot": "^1.2.4", + "@simple-module/i18n": "*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "i18next": "^23.15.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", @@ -25,6 +27,7 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.0.0", "react-hook-form": "^7.72.1", + "react-i18next": "^15.1.0", "react-resizable-panels": "^4.10.0", "recharts": "^3.8.0", "sonner": "^2.0.7", diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx index d2affbbd..89cde359 100644 --- a/host/client_app/pages/Error.tsx +++ b/host/client_app/pages/Error.tsx @@ -1,4 +1,5 @@ import { Link } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; import { ErrorScreen } from '@simple-module/ui/components/ErrorScreen'; import { Button } from '@simple-module/ui/components/ui/button'; @@ -7,29 +8,31 @@ interface Props { message: string; } -const titles: Record = { - 403: 'Forbidden', - 404: 'Page Not Found', - 500: 'Server Error', -}; +function ErrorPage({ status, message }: Props) { + const { t } = useT(); -const descriptions: Record = { - 403: "You don't have permission to access this page.", - 404: "The page you're looking for doesn't exist or has been moved.", - 500: 'Something went wrong on our end. Please try again later.', -}; + const titles: Record = { + 403: t(keys.host.error.forbidden_title), + 404: t(keys.host.error.not_found_title), + 500: t(keys.host.error.server_error_title), + }; -function ErrorPage({ status, message }: Props) { - const title = titles[status] || 'Error'; - const description = message || descriptions[status] || 'An unexpected error occurred.'; + const descriptions: Record = { + 403: t(keys.host.error.forbidden_description), + 404: t(keys.host.error.not_found_description), + 500: t(keys.host.error.server_error_description), + }; + + const title = titles[status] || t(keys.host.error.generic_title); + const description = message || descriptions[status] || t(keys.host.error.generic_description); return ( ); diff --git a/host/client_app/pages/Landing.tsx b/host/client_app/pages/Landing.tsx index 4d0de251..f25b67b7 100644 --- a/host/client_app/pages/Landing.tsx +++ b/host/client_app/pages/Landing.tsx @@ -1,4 +1,5 @@ import { usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; import { Badge } from '@simple-module/ui/components/ui/badge'; import { Button } from '@simple-module/ui/components/ui/button'; import { Card, CardContent } from '@simple-module/ui/components/ui/card'; @@ -11,6 +12,130 @@ interface Props { function Landing() { const { isAuthenticated } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + + const features = [ + { + icon: ( + + ), + title: t(keys.host.landing.features.module_system_title), + description: t(keys.host.landing.features.module_system_description), + }, + { + icon: ( + + ), + title: t(keys.host.landing.features.auth_title), + description: t(keys.host.landing.features.auth_description), + }, + { + icon: ( + + ), + title: t(keys.host.landing.features.schema_title), + description: t(keys.host.landing.features.schema_description), + }, + { + icon: ( + + ), + title: t(keys.host.landing.features.inertia_title), + description: t(keys.host.landing.features.inertia_description), + }, + { + icon: ( + + ), + title: t(keys.host.landing.features.diagnostics_title), + description: t(keys.host.landing.features.diagnostics_description), + }, + { + icon: ( + + ), + title: t(keys.host.landing.features.devtools_title), + description: t(keys.host.landing.features.devtools_description), + }, + ]; return ( <> @@ -22,7 +147,7 @@ function Landing() { className="border-primary-400/20 bg-primary-400/10 text-primary-300 mb-6 sm:mb-8 gap-2" > - Built with FastAPI + Inertia.js + React + {t(keys.host.landing.badge)} @@ -30,10 +155,10 @@ function Landing() { className="text-3xl font-extrabold tracking-tight leading-tight font-[var(--font-display)] sm:text-5xl lg:text-6xl animate-fade-in-up" style={{ animationDelay: '100ms' }} > - Modular Monolith + {t(keys.host.landing.hero_title_line1)}
- Framework for Python + {t(keys.host.landing.hero_title_line2)} @@ -41,8 +166,7 @@ function Landing() { className="mt-4 text-base text-dark-text-muted max-w-2xl mx-auto leading-relaxed sm:mt-6 sm:text-lg animate-fade-in-up" style={{ animationDelay: '200ms' }} > - Build scalable applications with independent modules, each with its own database schema, - API endpoints, and React pages — all in one deployable unit. + {t(keys.host.landing.hero_subtitle)}

@@ -93,134 +221,5 @@ function Landing() { ); } -const features = [ - { - icon: ( - - ), - title: 'Module System', - description: - 'Each module is a self-contained package with its own models, services, API endpoints, and React pages. Discovered automatically via Python entry_points.', - }, - { - icon: ( - - ), - title: 'Keycloak Auth', - description: - 'Cookie-based OIDC authentication with Keycloak. Server-side sessions, permission-based access control, and role-filtered menus.', - }, - { - icon: ( - - ), - title: 'Schema Isolation', - description: - 'Each module gets its own database schema on PostgreSQL or table prefix on SQLite. Full audit trails, soft deletes, and multi-tenancy built in.', - }, - { - icon: ( - - ), - title: 'Inertia.js + React', - description: - 'Server-driven SPA — FastAPI renders props, React renders the UI. No separate API client, no state duplication, full-stack type safety.', - }, - { - icon: ( - - ), - title: 'Diagnostics', - description: - 'Built-in module validator catches orphan pages, phantom renders, unguarded endpoints, and circular dependencies at startup.', - }, - { - icon: ( - - ), - title: 'Developer Tools', - description: - 'uv workspaces, Tailwind CSS 4, Vite HMR, auto-discovered pages, 97 tests in 0.4s, and a CLI scaffolding tool.', - }, -]; - Landing.layout = (page: React.ReactNode) => {page}; export default Landing; diff --git a/host/locales/en.json b/host/locales/en.json new file mode 100644 index 00000000..4b3dcd2d --- /dev/null +++ b/host/locales/en.json @@ -0,0 +1,37 @@ +{ + "landing": { + "badge": "Built with FastAPI + Inertia.js + React", + "hero_title_line1": "Modular Monolith", + "hero_title_line2": "Framework for Python", + "hero_subtitle": "Build scalable applications with independent modules, each with its own database schema, API endpoints, and React pages — all in one deployable unit.", + "cta_dashboard": "Open Dashboard", + "cta_get_started": "Get Started", + "cta_docs": "Documentation", + "features": { + "module_system_title": "Module System", + "module_system_description": "Each module is a self-contained package with its own models, services, API endpoints, and React pages. Discovered automatically via Python entry_points.", + "auth_title": "Keycloak Auth", + "auth_description": "Cookie-based OIDC authentication with Keycloak. Server-side sessions, permission-based access control, and role-filtered menus.", + "schema_title": "Schema Isolation", + "schema_description": "Each module gets its own database schema on PostgreSQL or table prefix on SQLite. Full audit trails, soft deletes, and multi-tenancy built in.", + "inertia_title": "Inertia.js + React", + "inertia_description": "Server-driven SPA — FastAPI renders props, React renders the UI. No separate API client, no state duplication, full-stack type safety.", + "diagnostics_title": "Diagnostics", + "diagnostics_description": "Built-in module validator catches orphan pages, phantom renders, unguarded endpoints, and circular dependencies at startup.", + "devtools_title": "Developer Tools", + "devtools_description": "uv workspaces, Tailwind CSS 4, Vite HMR, auto-discovered pages, 97 tests in 0.4s, and a CLI scaffolding tool." + } + }, + "error": { + "generic_title": "Error", + "generic_description": "An unexpected error occurred.", + "forbidden_title": "Forbidden", + "forbidden_description": "You don't have permission to access this page.", + "not_found_title": "Page Not Found", + "not_found_description": "The page you're looking for doesn't exist or has been moved.", + "server_error_title": "Server Error", + "server_error_description": "Something went wrong on our end. Please try again later.", + "go_home": "Go Home", + "go_back": "Go Back" + } +} diff --git a/host/locales/es.json b/host/locales/es.json new file mode 100644 index 00000000..e5e3b992 --- /dev/null +++ b/host/locales/es.json @@ -0,0 +1,37 @@ +{ + "landing": { + "badge": "Hecho con FastAPI + Inertia.js + React", + "hero_title_line1": "Monolito modular", + "hero_title_line2": "Framework para Python", + "hero_subtitle": "Construye aplicaciones escalables con módulos independientes, cada uno con su propio esquema de base de datos, endpoints de API y páginas de React — todo en una sola unidad desplegable.", + "cta_dashboard": "Abrir panel", + "cta_get_started": "Comenzar", + "cta_docs": "Documentación", + "features": { + "module_system_title": "Sistema de módulos", + "module_system_description": "Cada módulo es un paquete autocontenido con sus propios modelos, servicios, endpoints de API y páginas de React. Descubierto automáticamente vía entry_points de Python.", + "auth_title": "Autenticación Keycloak", + "auth_description": "Autenticación OIDC basada en cookies con Keycloak. Sesiones del lado del servidor, control de acceso por permisos y menús filtrados por rol.", + "schema_title": "Aislamiento de esquema", + "schema_description": "Cada módulo obtiene su propio esquema de base de datos en PostgreSQL o prefijo de tabla en SQLite. Auditoría completa, borrado lógico y multi-tenancy incorporados.", + "inertia_title": "Inertia.js + React", + "inertia_description": "SPA dirigida por el servidor — FastAPI renderiza props, React renderiza la UI. Sin cliente API separado, sin duplicación de estado, seguridad de tipos de punta a punta.", + "diagnostics_title": "Diagnósticos", + "diagnostics_description": "El validador de módulos incorporado detecta páginas huérfanas, renders fantasma, endpoints sin protección y dependencias circulares al inicio.", + "devtools_title": "Herramientas de desarrollo", + "devtools_description": "Workspaces de uv, Tailwind CSS 4, Vite HMR, páginas auto-descubiertas, 97 pruebas en 0.4s y una herramienta CLI de andamiaje." + } + }, + "error": { + "generic_title": "Error", + "generic_description": "Ocurrió un error inesperado.", + "forbidden_title": "Prohibido", + "forbidden_description": "No tienes permiso para acceder a esta página.", + "not_found_title": "Página no encontrada", + "not_found_description": "La página que buscas no existe o ha sido movida.", + "server_error_title": "Error del servidor", + "server_error_description": "Algo salió mal de nuestro lado. Por favor, inténtalo de nuevo más tarde.", + "go_home": "Ir al inicio", + "go_back": "Volver" + } +} diff --git a/host/main.py b/host/main.py index 85076599..29e54a3d 100644 --- a/host/main.py +++ b/host/main.py @@ -14,6 +14,7 @@ from simple_module_hosting.logging import setup_logging from host.routes import router as host_router +from host.routes_i18n import router as i18n_router settings = Settings() @@ -24,6 +25,7 @@ app = create_app(settings) app.include_router(host_router) +app.include_router(i18n_router) if __name__ == "__main__": import uvicorn diff --git a/host/pyproject.toml b/host/pyproject.toml index dc2b25ac..ed15268a 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "auth", "dashboard", "products", + "python-multipart>=0.0.6", ] [tool.uv.sources] diff --git a/host/routes_i18n.py b/host/routes_i18n.py new file mode 100644 index 00000000..edcf19bf --- /dev/null +++ b/host/routes_i18n.py @@ -0,0 +1,87 @@ +"""Locale switcher endpoint. + +POST /i18n/set-locale with form body ``locale=``. Validates against +the host's supported locales, sets a 1-year cookie, and 303-redirects to +a same-origin Referer (falls back to ``/`` for off-origin or missing). +""" + +from __future__ import annotations + +from urllib.parse import urlsplit + +from fastapi import APIRouter, Form, HTTPException, Request +from starlette.responses import RedirectResponse + +router = APIRouter() + +_ONE_YEAR_SECONDS = 60 * 60 * 24 * 365 + + +def _safe_redirect_target(request: Request) -> str: + """Return the Referer iff it's same-origin; otherwise fall back to ``/``. + + An attacker can control the ``Referer`` header (e.g. via a crafted form on + a third-party site). We only honor references that (a) resolve to the + same scheme+host as the current request, or (b) are relative paths that + don't try to escape to a protocol-relative URL (``//evil.example``). + """ + referer = request.headers.get("referer") + if not referer: + return "/" + + # Reject protocol-relative URLs like "//evil.example/foo" that browsers + # would resolve against the origin but a crafted Referer could use to + # leave the site. + if referer.startswith("//"): + return "/" + + parsed = urlsplit(referer) + # Relative path with no scheme+host → same-origin by construction. + if not parsed.scheme and not parsed.netloc: + return referer if referer.startswith("/") else "/" + + # Absolute URL → must match the current request's origin. + current = request.url + if parsed.scheme == current.scheme and parsed.netloc == current.netloc: + # Preserve the path + query. + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + return path + + return "/" + + +@router.post("/i18n/set-locale", response_model=None) +async def set_locale(request: Request, locale: str = Form(...)) -> RedirectResponse: + """Persist the user's locale choice in a long-lived cookie. + + Validates the requested locale against ``available_locales()`` (locales + with loaded messages) rather than the raw configured supported list, so + the endpoint can't accept a locale that would render a blank UI. + """ + registry = getattr(request.app.state, "i18n_registry", None) + if registry is not None: + supported = registry.available_locales() + else: + # Fallback for tests that build a minimal app without the registry. + supported = request.app.state.settings_supported_locales + cookie_name: str = request.app.state.settings_cookie_name + + if locale not in supported: + raise HTTPException( + status_code=422, + detail=f"Unsupported locale '{locale}' (available: {', '.join(supported)})", + ) + + destination = _safe_redirect_target(request) + response = RedirectResponse(destination, status_code=303) + response.set_cookie( + key=cookie_name, + value=locale, + max_age=_ONE_YEAR_SECONDS, + path="/", + samesite="lax", + httponly=False, + ) + return response diff --git a/host/tests/test_routes_i18n.py b/host/tests/test_routes_i18n.py new file mode 100644 index 00000000..8f1fc6f7 --- /dev/null +++ b/host/tests/test_routes_i18n.py @@ -0,0 +1,91 @@ +"""Tests for the locale-switcher endpoint.""" + +from __future__ import annotations + +from fastapi import FastAPI +from starlette.testclient import TestClient + +from host.routes_i18n import router as i18n_router + + +def _build_app(supported: list[str]) -> FastAPI: + app = FastAPI() + app.state.settings_supported_locales = supported + app.state.settings_cookie_name = "locale" + app.include_router(i18n_router) + return app + + +def test_sets_cookie_on_valid_locale() -> None: + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post( + "/i18n/set-locale", + data={"locale": "es"}, + headers={"Referer": "/dashboard"}, + ) + assert resp.status_code == 303 + assert resp.headers["location"] == "/dashboard" + cookie = resp.cookies.get("locale") + assert cookie == "es" + + +def test_rejects_unsupported_locale() -> None: + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post("/i18n/set-locale", data={"locale": "de"}) + assert resp.status_code == 422 + + +def test_redirects_to_root_when_no_referer() -> None: + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post("/i18n/set-locale", data={"locale": "es"}) + assert resp.status_code == 303 + assert resp.headers["location"] == "/" + + +def test_rejects_off_origin_referer() -> None: + """Attacker-controlled Referer must not become an open redirect.""" + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post( + "/i18n/set-locale", + data={"locale": "es"}, + headers={"Referer": "https://evil.example/steal"}, + ) + assert resp.status_code == 303 + assert resp.headers["location"] == "/" + + +def test_rejects_protocol_relative_referer() -> None: + """Protocol-relative Referer (``//evil.example``) must not escape origin.""" + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post( + "/i18n/set-locale", + data={"locale": "es"}, + headers={"Referer": "//evil.example/path"}, + ) + assert resp.status_code == 303 + assert resp.headers["location"] == "/" + + +def test_accepts_same_origin_absolute_referer() -> None: + """Absolute Referer matching the request's origin is preserved.""" + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + # TestClient's default base is http://testserver/ — match that. + resp = client.post( + "/i18n/set-locale", + data={"locale": "es"}, + headers={"Referer": "http://testserver/products?q=pen"}, + ) + assert resp.status_code == 303 + assert resp.headers["location"] == "/products?q=pen" + + +def test_rejects_relative_referer_without_leading_slash() -> None: + """Malformed relative Referer without leading slash falls back to ``/``.""" + client = TestClient(_build_app(["en", "es"]), follow_redirects=False) + resp = client.post( + "/i18n/set-locale", + data={"locale": "es"}, + headers={"Referer": "not-a-path"}, + ) + assert resp.status_code == 303 + assert resp.headers["location"] == "/" diff --git a/modules/auth/auth/deps.py b/modules/auth/auth/deps.py index c8bb0e9e..c4594ef1 100644 --- a/modules/auth/auth/deps.py +++ b/modules/auth/auth/deps.py @@ -5,18 +5,19 @@ from typing import Annotated from fastapi import Depends, HTTPException, Request +from simple_module_hosting.i18n_deps import TranslatorDep from auth.contracts.schemas import UserContext -async def get_current_user(request: Request) -> UserContext: +async def get_current_user(request: Request, t: TranslatorDep) -> UserContext: """Extract the authenticated user from request state. The auth middleware must set ``request.state.user`` before this runs. """ user = getattr(request.state, "user", None) if user is None: - raise HTTPException(status_code=401, detail="Not authenticated") + raise HTTPException(status_code=401, detail=t.t("auth.errors.not_authenticated")) return user @@ -32,7 +33,11 @@ def require_permission(*permissions: str): async def create_product(...): ... """ - async def check(request: Request, user: UserContext = Depends(get_current_user)): + async def check( + request: Request, + t: TranslatorDep, + user: UserContext = Depends(get_current_user), + ): # Admin role bypasses permission checks if "admin" in user.roles: return @@ -44,7 +49,10 @@ async def check(request: Request, user: UserContext = Depends(get_current_user)) if not any(p in user_perms for p in permissions): raise HTTPException( status_code=403, - detail=f"Missing required permission: {', '.join(permissions)}", + detail=t.t( + "auth.errors.missing_permission", + permissions=", ".join(permissions), + ), ) return Depends(check) diff --git a/modules/auth/auth/locales/en.json b/modules/auth/auth/locales/en.json new file mode 100644 index 00000000..72c5939a --- /dev/null +++ b/modules/auth/auth/locales/en.json @@ -0,0 +1,6 @@ +{ + "errors": { + "not_authenticated": "Not authenticated", + "missing_permission": "Missing required permission: {permissions}" + } +} diff --git a/modules/auth/auth/locales/es.json b/modules/auth/auth/locales/es.json new file mode 100644 index 00000000..b8730241 --- /dev/null +++ b/modules/auth/auth/locales/es.json @@ -0,0 +1,6 @@ +{ + "errors": { + "not_authenticated": "No autenticado", + "missing_permission": "Falta el permiso requerido: {permissions}" + } +} diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 9463fe38..acd223c0 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -19,7 +19,15 @@ logger = logging.getLogger(__name__) # Paths that don't require authentication -PUBLIC_PATHS = ("/auth/", "/health", "/static/", "/api/docs", "/api/redoc", "/openapi.json") +PUBLIC_PATHS = ( + "/auth/", + "/health", + "/static/", + "/api/docs", + "/api/redoc", + "/openapi.json", + "/i18n/", # locale switcher — must work for anonymous users +) EXACT_PUBLIC_PATHS = ("/",) diff --git a/modules/auth/auth/module.py b/modules/auth/auth/module.py index cc9db144..caa4beab 100644 --- a/modules/auth/auth/module.py +++ b/modules/auth/auth/module.py @@ -2,6 +2,8 @@ from __future__ import annotations +import importlib.resources +from pathlib import Path from typing import TYPE_CHECKING from fastapi import APIRouter @@ -57,3 +59,6 @@ def register_menu_items(self, registry: MenuRegistry) -> None: section=MenuSection.USER_DROPDOWN, ) ) + + def locale_dirs(self) -> dict[str, Path]: + return {"auth": Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/auth/tests/test_deps.py b/modules/auth/tests/test_deps.py index 729036df..cec1acac 100644 --- a/modules/auth/tests/test_deps.py +++ b/modules/auth/tests/test_deps.py @@ -2,12 +2,28 @@ from __future__ import annotations +import importlib.resources +from pathlib import Path from unittest.mock import MagicMock import pytest from auth.contracts.schemas import UserContext from auth.deps import get_current_user, require_permission from fastapi import HTTPException +from simple_module_core.i18n import I18nRegistry, Translator + + +def _translator() -> Translator: + """Build a Translator loaded with the auth module's ``en.json`` locale. + + This exercises the real message templates so assertions on rendered + detail strings remain meaningful. + """ + locales = Path(str(importlib.resources.files("auth") / "locales")) + registry = I18nRegistry(default_locale="en", supported_locales=["en"]) + registry.add_source("auth", locales) + registry.load() + return Translator(registry, locale="en", default_locale="en") class TestGetCurrentUser: @@ -17,7 +33,7 @@ async def test_raises_401_when_no_user(self): del request.state.user with pytest.raises(HTTPException) as exc_info: - await get_current_user(request) + await get_current_user(request, _translator()) assert exc_info.value.status_code == 401 async def test_returns_user_when_present(self): @@ -26,7 +42,7 @@ async def test_returns_user_when_present(self): request = MagicMock() request.state.user = user - result = await get_current_user(request) + result = await get_current_user(request, _translator()) assert result.id == "u1" @@ -42,7 +58,7 @@ async def test_raises_403_when_missing_permission(self, app): user = UserContext(id="u1", email="u@test.com", name="User", roles=["viewer"]) with pytest.raises(HTTPException) as exc_info: - await check_fn(request, user) + await check_fn(request, _translator(), user) assert exc_info.value.status_code == 403 async def test_admin_bypasses_permission_check(self, app): @@ -54,7 +70,7 @@ async def test_admin_bypasses_permission_check(self, app): request.app.state.perm_registry = app.state.perm_registry admin_user = UserContext(id="a1", email="admin@test.com", name="Admin", roles=["admin"]) - await check_fn(request, admin_user) + await check_fn(request, _translator(), admin_user) class TestRequirePermissionAdvanced: @@ -67,7 +83,7 @@ async def test_multiple_permissions_any_match(self, app): request.app.state.perm_registry = app.state.perm_registry admin = UserContext(id="a1", email="a@t.com", name="Admin", roles=["admin"]) - await check_fn(request, admin) + await check_fn(request, _translator(), admin) async def test_non_admin_without_permission_fails(self, app): dep = require_permission("products.delete") @@ -78,6 +94,6 @@ async def test_non_admin_without_permission_fails(self, app): user = UserContext(id="u1", email="u@t.com", name="User", roles=["user"]) with pytest.raises(HTTPException) as exc_info: - await check_fn(request, user) + await check_fn(request, _translator(), user) assert exc_info.value.status_code == 403 assert "products.delete" in str(exc_info.value.detail) diff --git a/modules/dashboard/dashboard/endpoints/views.py b/modules/dashboard/dashboard/endpoints/views.py index 46553ac2..d9b465af 100644 --- a/modules/dashboard/dashboard/endpoints/views.py +++ b/modules/dashboard/dashboard/endpoints/views.py @@ -8,17 +8,18 @@ from fastapi import APIRouter from inertia import InertiaResponse +from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.inertia_deps import InertiaDep router = APIRouter() @router.get("/", response_model=None) -async def dashboard(inertia: InertiaDep) -> InertiaResponse: +async def dashboard(inertia: InertiaDep, t: TranslatorDep) -> InertiaResponse: """Authenticated dashboard — requires login (enforced by AuthMiddleware).""" return await inertia.render( "Dashboard/Home", { - "welcome": "Welcome to SimpleModule", + "welcome": t.t("dashboard.home.welcome_message"), }, ) diff --git a/modules/dashboard/dashboard/locales/en.json b/modules/dashboard/dashboard/locales/en.json new file mode 100644 index 00000000..27a6168b --- /dev/null +++ b/modules/dashboard/dashboard/locales/en.json @@ -0,0 +1,14 @@ +{ + "home": { + "title": "Dashboard", + "description": "Overview of your application", + "stats": { + "products": "Products", + "users": "Users", + "modules": "Modules" + }, + "welcome_card_title": "Welcome", + "welcome_message": "Welcome to SimpleModule", + "description_body": "This is a modular monolith built with FastAPI, Inertia.js, and React. Each module provides its own pages, API endpoints, and database schema." + } +} diff --git a/modules/dashboard/dashboard/locales/es.json b/modules/dashboard/dashboard/locales/es.json new file mode 100644 index 00000000..2ceade3c --- /dev/null +++ b/modules/dashboard/dashboard/locales/es.json @@ -0,0 +1,14 @@ +{ + "home": { + "title": "Panel", + "description": "Resumen de tu aplicación", + "stats": { + "products": "Productos", + "users": "Usuarios", + "modules": "Módulos" + }, + "welcome_card_title": "Bienvenido", + "welcome_message": "Bienvenido a SimpleModule", + "description_body": "Este es un monolito modular construido con FastAPI, Inertia.js y React. Cada módulo proporciona sus propias páginas, endpoints de API y esquema de base de datos." + } +} diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index dbe14dcb..36001fac 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -2,6 +2,9 @@ from __future__ import annotations +import importlib.resources +from pathlib import Path + from fastapi import APIRouter from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated from simple_module_core.events import EventBus @@ -41,3 +44,6 @@ def register_event_handlers(self, bus: EventBus) -> None: bus.subscribe(ProductCreated, on_product_created) bus.subscribe(ProductUpdated, on_product_updated) bus.subscribe(ProductDeleted, on_product_deleted) + + def locale_dirs(self) -> dict[str, Path]: + return {"dashboard": Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index 6c52acbb..c57b3321 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -1,4 +1,5 @@ import { usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; import { PageShell } from '@simple-module/ui/components/PageShell'; import { Card, @@ -17,31 +18,44 @@ interface Props { function Home() { const { welcome } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); return ( - +
} accent="primary" /> - } accent="emerald" /> - } accent="violet" /> + } + accent="emerald" + /> + } + accent="violet" + />
- Welcome + + {t(keys.dashboard.home.welcome_card_title)} + {welcome} -

- This is a modular monolith built with FastAPI, Inertia.js, and React. Each module - provides its own pages, API endpoints, and database schema. -

+

{t(keys.dashboard.home.description_body)}

diff --git a/modules/products/products/endpoints/api.py b/modules/products/products/endpoints/api.py index ae527299..2cf3acb6 100644 --- a/modules/products/products/endpoints/api.py +++ b/modules/products/products/endpoints/api.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from simple_module_core.events import EventBus +from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.permissions import RequiresPermission from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated @@ -25,11 +26,12 @@ async def list_products( @router.get("/{product_id}", response_model=ProductOut) async def get_product( product_id: int, + t: TranslatorDep, service: ProductService = Depends(get_product_service), ) -> ProductOut: product = await service.get_by_id(product_id) if product is None: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException(status_code=404, detail=t.t("products.errors.not_found")) return product @@ -57,12 +59,13 @@ async def create_product( async def update_product( product_id: int, data: ProductUpdate, + t: TranslatorDep, service: ProductService = Depends(get_product_service), bus: EventBus = Depends(get_event_bus), ) -> ProductOut: product = await service.update(product_id, data) if product is None: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException(status_code=404, detail=t.t("products.errors.not_found")) await bus.publish(ProductUpdated(product_id=product.id, name=product.name)) return product @@ -74,10 +77,11 @@ async def update_product( ) async def delete_product( product_id: int, + t: TranslatorDep, service: ProductService = Depends(get_product_service), bus: EventBus = Depends(get_event_bus), ) -> None: deleted = await service.delete(product_id) if not deleted: - raise HTTPException(status_code=404, detail="Product not found") + raise HTTPException(status_code=404, detail=t.t("products.errors.not_found")) await bus.publish(ProductDeleted(product_id=product_id)) diff --git a/modules/products/products/endpoints/views.py b/modules/products/products/endpoints/views.py index fe009eee..c4dccc63 100644 --- a/modules/products/products/endpoints/views.py +++ b/modules/products/products/endpoints/views.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Query, Request from inertia import InertiaResponse from pydantic import ValidationError +from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.inertia_deps import InertiaDep from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict from simple_module_hosting.permissions import RequiresPermission @@ -61,11 +62,12 @@ async def create_view(inertia: InertiaDep) -> InertiaResponse: async def edit_view( product_id: int, inertia: InertiaDep, + t: TranslatorDep, service: ProductService = Depends(get_product_service), ) -> InertiaResponse: product = await service.get_by_id(product_id) if product is None: - return await inertia.render("Products/Browse", {"error": "Product not found"}) + return await inertia.render("Products/Browse", {"error": t.t("products.errors.not_found")}) return await inertia.render( "Products/Edit", {"product": product.model_dump(mode="json")}, diff --git a/modules/products/products/locales/en.json b/modules/products/products/locales/en.json new file mode 100644 index 00000000..85ff88f0 --- /dev/null +++ b/modules/products/products/locales/en.json @@ -0,0 +1,67 @@ +{ + "browse": { + "title": "Products", + "description": "Manage your product catalog", + "new_button": "New Product", + "search_placeholder": "Search products...", + "count_one": "{count} product", + "count_other": "{count} products", + "empty_title": "No products yet", + "empty_description": "Get started by creating your first product.", + "create_button": "Create Product", + "no_match": "No products match \"{query}\"" + }, + "table": { + "name": "Name", + "description": "Description", + "price": "Price", + "status": "Status", + "actions": "Actions", + "active": "Active", + "inactive": "Inactive" + }, + "delete_dialog": { + "title": "Delete \"{name}\"?", + "description": "This action cannot be undone. This will permanently delete the product from the catalog.", + "cancel": "Cancel", + "confirm": "Delete" + }, + "toasts": { + "deleted": "\"{name}\" deleted", + "delete_failed": "Failed to delete product", + "created": "Product created", + "updated": "Product updated" + }, + "form": { + "name_label": "Name", + "name_placeholder": "Enter product name", + "description_label": "Description", + "description_placeholder": "Optional description", + "price_label": "Price", + "price_placeholder": "0.00", + "active_label": "Active", + "cancel_button": "Cancel" + }, + "create": { + "title": "Create Product", + "description": "Add a new product to the catalog", + "submit_button": "Create Product", + "submitting_button": "Creating..." + }, + "edit": { + "title": "Edit: {name}", + "description": "Update product details", + "back_button": "Back to Products", + "submit_button": "Save Changes", + "submitting_button": "Saving..." + }, + "validation": { + "name_required": "Name is required", + "name_too_long": "Name must be under 200 characters", + "price_required": "Price is required", + "price_positive": "Price must be greater than 0" + }, + "errors": { + "not_found": "Product not found" + } +} diff --git a/modules/products/products/locales/es.json b/modules/products/products/locales/es.json new file mode 100644 index 00000000..f35b271d --- /dev/null +++ b/modules/products/products/locales/es.json @@ -0,0 +1,67 @@ +{ + "browse": { + "title": "Productos", + "description": "Administra tu catálogo de productos", + "new_button": "Nuevo producto", + "search_placeholder": "Buscar productos...", + "count_one": "{count} producto", + "count_other": "{count} productos", + "empty_title": "Aún no hay productos", + "empty_description": "Comienza creando tu primer producto.", + "create_button": "Crear producto", + "no_match": "Ningún producto coincide con \"{query}\"" + }, + "table": { + "name": "Nombre", + "description": "Descripción", + "price": "Precio", + "status": "Estado", + "actions": "Acciones", + "active": "Activo", + "inactive": "Inactivo" + }, + "delete_dialog": { + "title": "¿Eliminar \"{name}\"?", + "description": "Esta acción no se puede deshacer. Esto eliminará permanentemente el producto del catálogo.", + "cancel": "Cancelar", + "confirm": "Eliminar" + }, + "toasts": { + "deleted": "\"{name}\" eliminado", + "delete_failed": "No se pudo eliminar el producto", + "created": "Producto creado", + "updated": "Producto actualizado" + }, + "form": { + "name_label": "Nombre", + "name_placeholder": "Ingresa el nombre del producto", + "description_label": "Descripción", + "description_placeholder": "Descripción opcional", + "price_label": "Precio", + "price_placeholder": "0.00", + "active_label": "Activo", + "cancel_button": "Cancelar" + }, + "create": { + "title": "Crear producto", + "description": "Agrega un nuevo producto al catálogo", + "submit_button": "Crear producto", + "submitting_button": "Creando..." + }, + "edit": { + "title": "Editar: {name}", + "description": "Actualiza los detalles del producto", + "back_button": "Volver a productos", + "submit_button": "Guardar cambios", + "submitting_button": "Guardando..." + }, + "validation": { + "name_required": "El nombre es obligatorio", + "name_too_long": "El nombre debe tener menos de 200 caracteres", + "price_required": "El precio es obligatorio", + "price_positive": "El precio debe ser mayor que 0" + }, + "errors": { + "not_found": "Producto no encontrado" + } +} diff --git a/modules/products/products/module.py b/modules/products/products/module.py index 229e858b..2addfd0c 100644 --- a/modules/products/products/module.py +++ b/modules/products/products/module.py @@ -2,6 +2,9 @@ from __future__ import annotations +import importlib.resources +from pathlib import Path + from fastapi import APIRouter from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection @@ -53,3 +56,6 @@ def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: default_enabled=False, ) ) + + def locale_dirs(self) -> dict[str, Path]: + return {"products": Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/products/products/pages/Browse.tsx b/modules/products/products/pages/Browse.tsx index 6e8b8049..967f6617 100644 --- a/modules/products/products/pages/Browse.tsx +++ b/modules/products/products/pages/Browse.tsx @@ -1,4 +1,5 @@ import { Link, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; import { PageShell } from '@simple-module/ui/components/PageShell'; import { AlertDialog, @@ -62,6 +63,7 @@ function Browse() { pagination, search: initialSearch, } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); const { can } = usePermissions(); const canCreate = can('products.create'); const canEdit = can('products.edit'); @@ -93,21 +95,21 @@ function Browse() { function handleDelete(product: Product) { router.delete(`/products/${product.id}`, { - onSuccess: () => toast.success(`"${product.name}" deleted`), - onError: () => toast.error('Failed to delete product'), + onSuccess: () => toast.success(t(keys.products.toasts.deleted, { name: product.name })), + onError: () => toast.error(t(keys.products.toasts.delete_failed)), }); } return ( - New Product + {t(keys.products.browse.new_button)} ) : undefined @@ -118,7 +120,7 @@ function Browse() {
setSearch(e.target.value)} className="pl-9" @@ -126,7 +128,7 @@ function Browse() {
{pagination.total > 0 && (

- {pagination.total} product{pagination.total !== 1 ? 's' : ''} + {t(keys.products.browse.count, { count: pagination.total })}

)} @@ -135,12 +137,18 @@ function Browse() { - Name - Description - Price - Status + {t(keys.products.table.name)} + + {t(keys.products.table.description)} + + {t(keys.products.table.price)} + + {t(keys.products.table.status)} + {(canEdit || canDelete) && ( - Actions + + {t(keys.products.table.actions)} + )} @@ -152,7 +160,9 @@ function Browse() { {product.name} - {product.is_active ? 'Active' : 'Inactive'} + {product.is_active + ? t(keys.products.table.active) + : t(keys.products.table.inactive)} @@ -167,7 +177,9 @@ function Browse() { - {product.is_active ? 'Active' : 'Inactive'} + {product.is_active + ? t(keys.products.table.active) + : t(keys.products.table.inactive)} {(canEdit || canDelete) && ( @@ -193,19 +205,22 @@ function Browse() { - Delete "{product.name}"? + + {t(keys.products.delete_dialog.title, { name: product.name })} + - This action cannot be undone. This will permanently delete the - product from the catalog. + {t(keys.products.delete_dialog.description)} - Cancel + + {t(keys.products.delete_dialog.cancel)} + handleDelete(product)} className="bg-destructive text-white hover:bg-destructive/90" > - Delete + {t(keys.products.delete_dialog.confirm)} @@ -223,10 +238,10 @@ function Browse() { - No products yet - Get started by creating your first product. + {t(keys.products.browse.empty_title)} + {t(keys.products.browse.empty_description)} @@ -239,7 +254,9 @@ function Browse() { - No products match "{search}" + + {t(keys.products.browse.no_match, { query: search })} + diff --git a/modules/products/products/pages/Create.tsx b/modules/products/products/pages/Create.tsx index 34cc76b2..ecf4445e 100644 --- a/modules/products/products/pages/Create.tsx +++ b/modules/products/products/pages/Create.tsx @@ -1,4 +1,5 @@ import { Link, useForm } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; import { PageShell } from '@simple-module/ui/components/PageShell'; import { Button } from '@simple-module/ui/components/ui/button'; import { Card, CardContent } from '@simple-module/ui/components/ui/card'; @@ -7,9 +8,11 @@ import { Label } from '@simple-module/ui/components/ui/label'; import { Textarea } from '@simple-module/ui/components/ui/textarea'; import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; import { toast } from 'sonner'; -import { validateProduct } from './validation'; +import { useValidateProduct } from './validation'; function Create() { + const { t } = useT(); + const validateProduct = useValidateProduct(); const { data, setData, post, processing, errors, clearErrors } = useForm({ name: '', description: '', @@ -26,7 +29,7 @@ function Create() { return; } post('/products', { - onSuccess: () => toast.success('Product created'), + onSuccess: () => toast.success(t(keys.products.toasts.created)), onError: (errs) => { const first = Object.values(errs)[0]; if (first) toast.error(first); @@ -36,11 +39,11 @@ function Create() { return ( - Cancel + {t(keys.products.form.cancel_button)} } > @@ -49,7 +52,7 @@ function Create() {
@@ -67,20 +70,20 @@ function Create() {
- +