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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,15 @@ way.
MenuItem(
label="Users", # fallback, still required
label_key="users.nav.users", # module's own namespace
url="/users/admin",
group="Administration",
group_key="ui.nav_groups.administration", # shared vocabulary
url="/admin/users/",
group="Access",
group_key="ui.nav_groups.access", # shared vocabulary
)
```

Menus are translated **on the server**, in `MenuRegistry.get_for_user(translate=…)`, so the Inertia payload carries finished text and every render site (sidebar, topbar, command palette) keeps reading `item.label`. Two consequences worth knowing: an admin-audience module's labels don't need to be in the anonymous catalog snapshot to render, and a key that resolves to nothing falls back to `label` — a missing translation degrades to English, never to a raw dotted key on screen. Both fields are optional, so modules written before them keep working unchanged.

Group headers are shared across modules, so they live in the `ui` namespace (`ui.nav_groups.administration|system|content`) rather than each module inventing its own key — otherwise one module's "Administration" could translate differently from another's and split a single header in two.
Group headers are shared across modules, so they live in the `ui` namespace (`ui.nav_groups.access|appearance|content|system`) rather than each module inventing its own key — otherwise one module's "System" could translate differently from another's and split a single header in two. Those four are the whole vocabulary the bundled modules use; adding a fifth means adding the key to `packages/ui/locales/*.json` so every module can share it.
- `i18n` — active locale and translation bundle.

The framework does not know the shape of `auth.user`. The `auth` module registers a `principal_serializer: Callable[[UserContext], dict]` on `app.state.principal_serializer` during `register_settings(app)`; the middleware calls it with `request.state.user` to build the `auth.user` payload. Without a registered serializer, `auth.user` is `None` even when a user is authenticated.
Expand Down Expand Up @@ -364,9 +364,9 @@ test apps) are exempt.

### Error responses (HTML vs JSON)

403/404/422/500 are content-negotiated. Requests under `/api/*` — the
documented prefix for every module's JSON surface — or with an explicit
`Accept: application/json` get a JSON body:
401, 403, 404, 419, 422, 429, 500 and 503 are content-negotiated. Requests
under `/api/*` — the documented prefix for every module's JSON surface — or
with an explicit `Accept: application/json` get a JSON body:

```json
{ "detail": "Permission required: pagebuilder.edit" }
Expand All @@ -382,6 +382,18 @@ file-download `<a>` hrefs are real navigations to API paths); a bare
`fetch()` sends `Accept: */*` and gets JSON there. Module endpoint code doesn't opt in or
out — raise `HTTPException` as usual and the handler picks the right shape.

Each of those statuses has its own copy on the error page, so a 429 reads as
"too many requests" rather than a bare "an unexpected error occurred". 401 and
419 additionally offer **sign in** as the primary action, since that is the
actual remedy — the URL comes from the auth provider via `app.state` rather
than an import (framework code must not reach into modules, `SM009`), so an app
with no provider installed simply gets no button.

Headers the exception carries — `WWW-Authenticate` on a 401, `Retry-After` on a
429 or 503 — are passed through to both the rendered page and the JSON
fallback. Those statuses are the ones whose headers mean something, so moving
them onto the page-rendering branch would otherwise have dropped them.

### Design packs (site-wide look)

A *design pack* is a stylesheet a module ships that restyles the public site by
Expand Down
93 changes: 92 additions & 1 deletion docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,105 @@ Both are auto-discovered alongside module contributions — no manual wiring.

## Configuration

The three i18n fields are declared on `HostSettings`, but they are **read from the environment at boot**:

```bash
SM_I18N_DEFAULT_LOCALE=en
SM_I18N_SUPPORTED_LOCALES=en,es,de
SM_I18N_SUPPORTED_LOCALES='["en","es"]'
SM_I18N_COOKIE_NAME=locale
```

| Field / env var | Default | Purpose |
|---|---|---|
| `i18n_default_locale` · `SM_I18N_DEFAULT_LOCALE` | `en` | Language served when nothing else resolves, and the fallback every other locale layers over. |
| `i18n_supported_locales` · `SM_I18N_SUPPORTED_LOCALES` | `["en"]` | Which locales are served at all. The `<LocaleSwitcher />` hides itself when there is only one. |
| `i18n_cookie_name` · `SM_I18N_COOKIE_NAME` | `locale` | Cookie the switcher writes and `LocaleMiddleware` reads first. |

A pydantic validator enforces that the default locale is in the supported list.

### Why env, when `HostSettings` is the DB-backed class

`HostSettings` is consumed through **two different objects**, and i18n uses the env-backed one:

- `Settings` (`HostSettings` + `BootstrapSettings`) inherits `env_prefix="SM_"`, so every field on it resolves from `SM_*` at boot. This instance lands on `app.state.sm.settings`, and it is what `LocaleMiddleware`, the i18n manifest, the shared-props builder and `i18n_deps` all read.
- `app.state.host.settings` is a separate `HostSettings` hydrated from the DB. `HostSettings` on its own declares no `env_prefix`, so this instance is pydantic defaults plus stored overrides. `maintenance_mode` is read from here, which is what makes that flag live-editable.

The practical consequence: **changing the i18n values in the admin UI does not change locale resolution.** `LocaleMiddleware` captured its locale set at construction from the env-derived object, and a settings save swaps `app.state.host.settings`, not `app.state.sm.settings`. Moving the served locale set means setting the env var and restarting. The same is true of `multi_tenant` / `tenant_header` — `SM_MULTI_TENANT` is what decides whether `TenantMiddleware` is installed at all, and no DB write can install a middleware after boot.

`SM_I18N_SUPPORTED_LOCALES` is additionally read by the standalone diagnostics runner (`python -m simple_module_core`, i.e. `make doctor`), which has no DB: it skips the i18n checks entirely when the variable is unset.

Shipping a locale is not the same as enabling it. `es.json` files exist across this repo but `es` is not in the default supported list, so they are never loaded.

## Falling back across locales

Each non-default locale's snapshot is layered **over** the default locale's before it reaches the client, so an untranslated key renders in the default language rather than as a raw dotted key.

This matters more than it sounds. The client initialises i18next with `fallbackLng` set to the *active* locale, so there is no cross-locale fallback in the browser — a key missing from the payload renders as `dashboard.home.system_meta` on screen. The server-side `Translator` has always fallen back to the default locale, so without this the two paths disagreed: menu labels (translated server-side) rendered English while the page body showed keys.

The practical effect is that partial translation is a **safe, incremental state**. A half-finished locale reads as a mix of two languages, not as a screen of dotted keys.

## Translating menu labels and audit links

Some strings are chosen in Python and rendered on every page. Those are translated **server-side** rather than shipped to the client as keys.

`MenuItem` takes `label_key` and `group_key` alongside `label`/`group`; `MenuRegistry.get_for_user(translate=…)` resolves them, so the Inertia payload carries finished text and every render site (sidebar, topbar, ⌘K palette) keeps reading `item.label` untouched. `AuditLink.label_key` does the same for audit-log entity labels.

```python
MenuItem(
label="Users", # fallback, still required
label_key="users.nav.users", # module's own namespace
url="/admin/users/",
group="Access",
group_key="ui.nav_groups.access", # shared vocabulary
)
```

Two consequences worth knowing:

- An admin-audience module's labels don't need to be in the anonymous catalog snapshot to render, because they are resolved before the payload is built — this sidesteps the [audience](#audience) split.
- A key that resolves to nothing falls back to `label`. A missing translation degrades to English, never to a raw dotted key on screen.

Both fields are optional, so modules written before them keep working. Group headers are shared across modules and live in the `ui` namespace (`ui.nav_groups.access|appearance|content|system`) rather than each module inventing its own — otherwise one module's "System" could translate differently from another's and split a single header in two.

## The untranslated-string gate (`make ci-check-untranslated`)

`SM013`–`SM016` only compare catalogs against *each other*, so with `i18n_supported_locales = ["en"]` they never fire — a module could ship a complete `en.json` that no page ever read and CI stayed green. `tsc` is equally happy with hardcoded English. This check is what notices.

It runs in `make lint` and as its own CI job, parsing every `.tsx` under `modules/*/*/`, `packages/ui/src/` and `host/client_app/` (skipping vendored shadcn primitives under `packages/ui/src/components/ui/`, plus `.test.tsx` and `.stories.tsx`). It fails on user-visible text rendered as a literal:

- JSX text — `<p>Save</p>`
- An allowlisted text attribute — `title`, `placeholder`, `aria-label`, `label`, `description`, `alt`, `emptyText`, `confirmLabel`, … The list is deliberately closed: `className`, `variant`, `role` and `type` carry machine tokens, and flagging those would train people to reach for the exemption instead of the catalog.
- A `toast.*` / `confirm` argument
- Copy hidden in a ternary — `cond ? 'Enabled' : 'Disabled'`

It parses rather than greps, which costs one devDependency (`@babel/parser`). No regex over JSX can distinguish `<p>Save</p>` from the `Promise<void>` in a type annotation, and one that tries flags both.

### Exempting a legitimate literal

Three escape hatches, in order of preference:

| How | When |
|---|---|
| Wrap in `<code>` / `<pre>` | Code samples, identifiers, terminal output — recognised automatically, no comment needed. |
| `// i18n-exempt: <reason>` | One line. A truncated token echo, a JSON example placeholder. |
| `// i18n-exempt-file: <reason>` | A whole file. Reserved for dev-only fixtures like `DemoPlaceholders.tsx`. |

Always give a reason — the comment is the only record of why the string is allowed to stay English.

### Known blind spot

A string that reaches the screen through a variable or config object (`const THEME = { mobileTitleLabel: 'Admin' }`) is invisible to this check. Catching it needs taint analysis; guessing instead would produce the false positives that get a check switched off. Documented rather than papered over.

The detection logic lives in `scripts/lib/untranslated-strings.mjs` behind its own unit tests, so a later "fix" to one of its heuristics cannot quietly stop it detecting anything.

## Type generation

`packages/i18n/src/keys.generated.ts` is generated over every **installed** module, not just the ones the running host activated, and `make ci-js-typecheck` runs `tsc -p` for each `modules/*/tsconfig.json` and `packages/*/tsconfig.json` in the workspace. (A module shipping `.tsx` with no `tsconfig.json` fails the target outright, rather than being silently skipped — the same gap `SM017` reports.)

That distinction is load-bearing: with a filtered union, translating an inactive auth provider's pages (say `keycloak`, when this host runs `users`) would break its build on the next regeneration while the app itself ran fine. The runtime registry stays filtered — an inactive module's strings are typed, never served.

`t()` needs literal keys to typecheck; a key assembled at runtime won't resolve.

## Diagnostics (`SM013`–`SM016`)

App boot runs `I18nDiagnostics` against every declared locale dir:
Expand Down
58 changes: 56 additions & 2 deletions docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ register_health_checks
register_public_routes
register_csp_sources
register_design_packs
register_audit_links
register_exception_handlers
register_middleware
register_routes
register_admin_routes (only when meta.admin_view_prefix is set)
-- on_startup (async, after middleware is installed)
```

Expand Down Expand Up @@ -99,12 +101,12 @@ if flags.is_enabled("orders.new_checkout", tenant_id=request.state.tenant_id):
...
```

## `register_event_handlers(bus)`
## `register_event_handlers(bus, app=None)`

Subscribe to events on the in-process `EventBus`. Handlers can be sync or async; the bus awaits async ones.

```python
def register_event_handlers(self, bus: EventBus) -> None:
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
from orders.contracts.events import OrderPlaced

bus.subscribe(OrderPlaced, self._on_order_placed)
Expand All @@ -115,6 +117,8 @@ async def _on_order_placed(self, event: OrderPlaced) -> None: ...

Handlers are keyed by the exact event type and run concurrently on publish. See [Events](/framework/events).

`app` is optional. Take it when a handler needs `app.state.sm.db.session_factory` to persist on the framework's engine rather than building its own. The framework inspects your signature and calls the one-argument form `(self, bus)` when that is what you declared, so modules written before `app` existed keep working unchanged.

## `register_health_checks(registry)`

Register named async checks. They're surfaced at `/health/ready`:
Expand Down Expand Up @@ -191,6 +195,56 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None

The framework **auto-applies** `ModuleMeta.route_prefix` / `view_prefix` — `create_app` constructs each router already prefixed (`APIRouter(prefix=module.meta.route_prefix)`), so your `include_router` calls usually pass no further prefix (add one only for a sub-grouping *inside* the module's own prefix).

## `register_admin_routes(admin_router)`

A **second** view router, for modules that serve both public and admin pages and so cannot express both under one `view_prefix`. Called only when `ModuleMeta.admin_view_prefix` is set; the router arrives pre-built with that prefix.

```python
meta = ModuleMeta(
name="Users",
view_prefix="/users", # sign-in, self-service
admin_view_prefix="/admin/users", # management CRUD
)


def register_admin_routes(self, admin_router: APIRouter) -> None:
from users.admin.views import router as admin_views

admin_router.include_router(admin_views)
```

A module gets exactly one view router, which is fine for a pure-admin module — it just points `view_prefix` at `/admin/...` and needs none of this. The second router exists for the cases where that doesn't work: `users` keeps `/users/login` public while its CRUD lives at `/admin/users`, and `dashboard` keeps `/dashboard/` while Doctor lives at `/admin/doctor`.

Both the field and the hook are additive and default to no-op, so a module written before they existed is unaffected.

> Putting a screen in the admin section means moving **three** things together: the URL (here), the menu registration (`MenuSection.ADMIN_SIDEBAR` in `register_menu_items`), and the layout the page renders in (`AdminLayout`). Change one and you get a page whose sidebar no longer lists it — a failure nothing about the diff makes obvious.

## `register_audit_links(registry)`

Teach the audit log how to link an entry back to the entity it describes, so a row reads as a link to the record rather than a bare type name.

```python
def register_audit_links(self, registry: AuditLinkRegistry) -> None:
from users.models import User

registry.register(
AuditLink(
entity_type=User.__name__, # class name — NOT __tablename__
url_template="/admin/users/{id}",
label="User",
label_key="users.audit.user",
)
)
```

`entity_type` matches `AuditEntry.entity_type`, which `snapshot_changes` writes as `type(obj).__name__`. Keying it off `__tablename__` (`"users_user"`) produces a link that never matches — and nothing errors: an unmatched lookup falls back to rendering `entity_type` as a plain label, so the row still shows text while silently never becoming a link. Using `Model.__name__` rather than a string literal makes a rename impossible to get wrong.

`url_template` must contain the literal `{id}` placeholder, substituted with the entity id. A template without it raises at boot rather than pointing every row at the same page.

`label_key` translates the label the same way `MenuItem.label_key` does, falling back to `label` when the key resolves to nothing. Rows are rendered server-side, so the audit view translates these before they reach the page.

The registry maps class names to URL templates and nothing more: it does not check that the row still exists or that the reader may open it. A link to a deleted record lands on the target screen's own 404, and permissions are enforced by the target route as usual.

## `on_startup()` / `on_shutdown()`

Async lifespan hooks that run after all modules are registered.
Expand Down
Loading
Loading