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 `_
@@ -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() {