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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,6 +85,9 @@ jobs:
cache: "npm"
- run: make install-js
- run: make ci-js-lint
# Catches user-visible text rendered as a literal instead of t(keys.…).
# Shipping a locales/en.json never proved a page actually read it.
- run: make ci-check-untranslated

js-typecheck:
name: JS typecheck
Expand Down
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,9 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b
- **Framework vs plugin coupling**: `SM009` is an error if `framework/*` directly imports from a plugin module. Framework code must not reach into `modules/`.
- **Zod schemas with translated messages** must be constructed inside a hook (`useT()`) — never at module scope, or they freeze against the first render's locale.
- **Locales**: ship `<package>/locales/<lang>.json` and declare in `ModuleBase.locale_dirs()` with the module's lowercase name as the namespace. `{"browse": {"title": "X"}}` flattens to `<namespace>.browse.title`. Pluralize with CLDR suffixes (`_one`, `_other`, ...).
- **No user-visible string literals in `.tsx`** — enforced by `make ci-check-untranslated`, see § CI. Every rendered string — JSX text, `placeholder`, `aria-label`, `<Head title>`, `toast.*()`, confirm text — goes through `t(keys.<namespace>.…)` from `@simple-module-py/i18n`. Exempt: shell commands, env-var names, and JSON examples shown as literal `<code>`. In non-component modules (a `retry.ts` helper) import the non-hook `t` and call it *inside* the function, never at module scope, or it freezes against the boot locale.
- **Menu labels**: set `label_key`/`group_key` on `MenuItem` next to `label`/`group`; group headers use the shared `ui.nav_groups.*` keys. Menus are translated server-side in `MenuRegistry.get_for_user(translate=…)`, and an unresolved key falls back to the literal `label`. See [docs/framework-conventions.md](docs/framework-conventions.md) § Shared props.
- Regenerate `packages/i18n/src/{keys.generated,generated-resources}.ts` after touching any catalog — booting the host in development does it, and `t()` only accepts keys present there.
- **Ty (type checker) false positives** from SQLModel: `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type` are all globally ignored in `pyproject.toml` because SQLModel declares fields with plain Python types while runtime instruments them as SQLAlchemy attributes. Do not re-enable these rules — real bugs surface in tests.

## Diagnostic codes
Expand All@@ -117,7 +120,11 @@ E2E tests live in `tests/e2e/` behind the `e2e` pytest marker and run against a

## CI

`.github/workflows/pr.yml` runs Python lint / typecheck / tests, JS lint / typecheck / tests, and the 300-line file-size check as parallel jobs; `make lint` locally runs the same checks serially. Branch protection requires the aggregate `pr-checks` job.
`.github/workflows/pr.yml` runs Python lint / typecheck / tests, JS lint / typecheck / tests, the 300-line file-size check, and the untranslated-string check as parallel jobs; `make lint` locally runs the same checks serially. Branch protection requires the aggregate `pr-checks` job.

`make ci-check-untranslated` (`scripts/check_untranslated_strings.mjs`) parses every `.tsx` and fails on user-visible text rendered as a literal — JSX text, a `title`/`placeholder`/`aria-label`-style attribute, or a `toast.*()`/`confirm()` argument — including copy hidden in `cond ? 'A' : 'B'`. It parses with `@babel/parser` rather than grepping, because no regex over JSX can tell `<p>Save</p>` from `Promise<void>`. It does **not** see strings passed through a variable or a config object (`const THEME = { mobileTitleLabel: 'Admin' }`), so those still need care.

To exempt a genuinely technical literal: wrap it in `<code>`/`<pre>`, or mark the line `// i18n-exempt: <reason>`; `i18n-exempt-file: <reason>` in a file's first lines skips the whole file.

## Authoritative references

Expand Down
11 changes: 9 additions & 2 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav 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 ci-check-hardcoded-strings ci-build-packages worker beat worker-docker
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav 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 ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker

# Install
install:
Expand DownExpand Up@@ -93,7 +93,7 @@ loadtest: ## Run locust against a server already on $(LOCUST_H
loadtest-memray: ## Start uvicorn under memray, load-test, emit flamegraph
scripts/loadtest_memray.sh $(LOCUST_ARGS)

lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated
uv run python scripts/check_metadata.py
uv run python scripts/check_readmes.py

Expand DownExpand Up@@ -135,6 +135,13 @@ ci-check-file-size:
ci-check-hardcoded-strings:
uv run python scripts/check_hardcoded_strings.py

# Fail when a .tsx renders user-visible text as a literal instead of t(keys.…).
# Shipping locales/en.json never proved a page actually read it: SM013-SM016
# only compare catalogs to each other, so with i18n_supported_locales=["en"]
# they never fire and tsc is happy with hardcoded English.
ci-check-untranslated:
node scripts/check_untranslated_strings.mjs

# Dry-run the release build: build sdists + wheels for every workspace member
# the same way release.yml does. Catches packaging regressions at PR time
# (e.g. force-include paths that crash the sdist→wheel rebuild) instead of
Expand Down
16 changes: 16 additions & 0 deletions docs/framework-conventions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -232,6 +232,22 @@ way.
Pick a band by audience, leave gaps of ~10 between siblings, and put module-specific user-dropdown items in the `900+` range (Profile=990, Logout=999).

Sidebar items can also set `group="<Label>"` on the `MenuItem` to render under a group header. The frontend clusters consecutive items with the same group label and prints the label as a section heading; the group's position is set by the lowest-`order` item that joins it. Built-in groups are `Content`, `Administration`, and `System`. Items with no `group` (the default) render flat — Dashboard intentionally stays ungrouped above the headed groups.

**Translating menu labels.** Set `label_key` (and `group_key`) alongside `label`/`group` to name a catalog entry:

```python
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
)
```

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.
- `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
5 changes: 5 additions & 0 deletions framework/core/simple_module_core/audit_links.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,11 +39,16 @@ class AuditLink:
(e.g. ``"/admin/users/{id}/edit"``).
label: Human-readable name for the entity kind, shown instead of the
raw class name (e.g. ``"User account"``).
label_key: Catalog key for ``label``. Empty, or unresolved, falls back
to ``label`` — a missing translation shows English rather than a
raw dotted key. Rows are rendered server-side, so the audit view
translates these before they reach the page.
"""

entity_type: str
url_template: str
label: str = ""
label_key: str = ""

def __post_init__(self) -> None:
if _ID_PLACEHOLDER not in self.url_template:
Expand Down
24 changes: 22 additions & 2 deletions framework/core/simple_module_core/i18n.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,8 +145,18 @@ def load(self) -> None:
# Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs
# once here rather than on every Inertia render. The public variant
# (admin namespaces excluded) is what anonymous visitors receive.
self._message_snapshots = {locale: dict(msgs) for locale, msgs in self._messages.items()}
self._public_snapshots = public_messages
#
# Each non-default locale layers over the default one, so a key it has
# not translated yet resolves to the default language instead of
# reaching the browser missing. The client cannot recover on its own:
# i18next is initialised with ``fallbackLng`` equal to the active
# locale, so an absent key renders as the raw dotted key — a partially
# translated locale would put "dashboard.home.health" on screen. The
# server-side ``Translator`` has always fallen back this way; this
# makes the payload behave the same, and makes partial translations a
# safe, incremental state to be in.
self._message_snapshots = self._layered_snapshots(self._messages)
self._public_snapshots = self._layered_snapshots(public_messages)
self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs)
self._available_locales_list = list(self._available_locales)
self._loaded = True
Expand All@@ -162,6 +172,16 @@ def available_locales(self) -> list[str]:
return self._available_locales_list
return [locale for locale, msgs in self._messages.items() if msgs]

def _layered_snapshots(self, messages: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
"""Snapshot per locale, each layered over the default locale's."""
base = messages.get(self.default_locale, {})
return {
locale: dict(msgs)
if locale == self.default_locale
else {**base, **msgs} # translated entries win; the rest read as default
for locale, msgs in messages.items()
}

def messages(self, locale: str) -> Mapping[str, str]:
"""Flat dotted-key map for the given locale. Empty mapping if unknown.

Expand Down
36 changes: 34 additions & 2 deletions framework/core/simple_module_core/menu.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Literal
Expand All@@ -24,6 +25,15 @@ class MenuItem:

label: str
url: str
label_key: str = ""
"""Catalog key for ``label``. Empty = ship ``label`` verbatim.

Menu labels are rendered on every page, so they are translated on the
server — the payload carries finished text and every render site (sidebar,
topbar, command palette) keeps working untouched. A key that resolves to
nothing falls back to ``label``, so a missing translation degrades to
English rather than to a raw dotted key on screen.
"""
icon: str = ""
order: int = 0
section: MenuSection = MenuSection.SIDEBAR
Expand All@@ -42,6 +52,14 @@ class MenuItem:
method: MenuItemMethod = "get"
"""HTTP method used when the item is activated. ``"post"`` renders as an
Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
group_key: str = ""
"""Catalog key for ``group``, with the same fallback rule as ``label_key``.

Group headers are shared vocabulary — several modules file entries under
"Administration" — so they live in the ``ui`` namespace. Letting each
module invent its own key would let one translation drift from another and
split a single header in two.
"""
group: str = ""
"""Sidebar group label. Empty = ungrouped (renders flat, no header).
Items in the same section that share a group are visually clustered under a
Expand DownExpand Up@@ -79,19 +97,33 @@ def get_for_user(
is_authenticated: bool,
roles: list[str] | None = None,
permissions: list[str] | None = None,
translate: Callable[[str], str] | None = None,
) -> dict[str, list[dict]]:
"""Return menu items grouped by section, filtered by auth/roles/permissions.

``permissions`` is the caller's already-expanded permission list (no
wildcards). Items declaring permissions the caller lacks are dropped,
so the sidebar never offers a screen that will 403 on click.

``translate`` resolves ``label_key``/``group_key`` against the request's
locale. Omitting it (or omitting the keys) ships the literal ``label``
and ``group``, which is what third-party modules predating the keys do.

Returns a dict ready to be serialized into Inertia shared props.
"""
roles = roles or []
granted = set(permissions or [])
result: dict[str, list[dict]] = {s.value: [] for s in MenuSection}

def render(key: str, fallback: str) -> str:
# Translator.t() echoes the key back when the catalog has no entry.
# Showing "users.nav.users" in the sidebar would be worse than the
# English it replaced, so an unresolved key keeps the literal.
if not key or translate is None:
return fallback
translated = translate(key)
return fallback if translated == key else translated

for item in self.all_items:
if item.requires_auth and not is_authenticated:
continue
Expand All@@ -103,11 +135,11 @@ def get_for_user(
continue
result[item.section.value].append(
{
"label": item.label,
"label": render(item.label_key, item.label),
"url": item.url,
"icon": item.icon,
"method": item.method,
"group": item.group,
"group": render(item.group_key, item.group),
}
)

Expand Down
35 changes: 35 additions & 0 deletions framework/core/tests/test_i18n.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,41 @@ def test_loads_single_namespace(self, tmp_path: Path) -> None:
reg.load()
assert reg.messages("en") == {"products.browse.title": "Products"}

def test_snapshot_falls_back_to_the_default_locale(self, tmp_path: Path) -> None:
"""A partially translated locale must not ship missing keys.

The client initialises i18next with ``fallbackLng`` equal to the active
locale, so a key absent from the payload renders as the raw dotted key
— "dashboard.home.health" on screen. Layering over the default locale
makes an untranslated key read as the default language instead, which
is what the server-side Translator has always done.
"""
self._write_locale(tmp_path / "p", "en", {"title": "Products", "new": "Brand new"})
self._write_locale(tmp_path / "p", "es", {"title": "Productos"})
reg = I18nRegistry(default_locale="en", supported_locales=["en", "es"])
reg.add_source("products", tmp_path / "p")
reg.load()

snapshot = reg.messages_snapshot("es")
assert snapshot["products.title"] == "Productos"
assert snapshot["products.new"] == "Brand new", "untranslated key must read as English"

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

def test_default_locale_snapshot_is_untouched(self, tmp_path: Path) -> None:
self._write_locale(tmp_path / "p", "en", {"title": "Products"})
self._write_locale(tmp_path / "p", "es", {"title": "Productos"})
reg = I18nRegistry(default_locale="en", supported_locales=["en", "es"])
reg.add_source("products", tmp_path / "p")
reg.load()
assert reg.messages_snapshot("en") == {"products.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"})
Expand Down
59 changes: 59 additions & 0 deletions framework/core/tests/test_menu.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,62 @@ async def test_omitting_permissions_entirely_hides_gated_entries(self):
reg = MenuRegistry()
reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
assert reg.get_for_user(is_authenticated=True)["sidebar"] == []


_CATALOG = {
"users.nav.users": "Usuarios",
"ui.nav_groups.administration": "Administración",
}


class TestMenuLabelTranslation:
"""Label/group keys resolve server-side so every render site stays dumb."""

def _translate(self, key: str) -> str:
# Mirrors Translator.t(), which echoes the key back when it is missing.
return _CATALOG.get(key, key)

async def test_keys_are_translated(self):
reg = MenuRegistry()
reg.add(
MenuItem(
label="Users",
url="/users/admin",
label_key="users.nav.users",
group="Administration",
group_key="ui.nav_groups.administration",
)
)
item = reg.get_for_user(is_authenticated=True, translate=self._translate)["sidebar"][0]
assert item["label"] == "Usuarios"
assert item["group"] == "Administración"

async def test_missing_key_falls_back_to_the_literal_label(self):
"""An unresolved key must not put a raw dotted key on screen."""
reg = MenuRegistry()
reg.add(
MenuItem(
label="Reports",
url="/reports",
label_key="reports.nav.absent",
group="Ops",
group_key="ui.nav_groups.absent",
)
)
item = reg.get_for_user(is_authenticated=True, translate=self._translate)["sidebar"][0]
assert item["label"] == "Reports"
assert item["group"] == "Ops"

async def test_items_without_keys_ship_their_literal_label(self):
"""Third-party modules predating label_key keep working unchanged."""
reg = MenuRegistry()
reg.add(MenuItem(label="Legacy", url="/legacy", group="Tools"))
item = reg.get_for_user(is_authenticated=True, translate=self._translate)["sidebar"][0]
assert item["label"] == "Legacy"
assert item["group"] == "Tools"

async def test_no_translator_ships_literal_labels(self):
reg = MenuRegistry()
reg.add(MenuItem(label="Users", url="/users/admin", label_key="users.nav.users"))
item = reg.get_for_user(is_authenticated=True)["sidebar"][0]
assert item["label"] == "Users"
Loading