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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b

### Fixed

- Apps scaffolded with `smpy new` now keep a separate i18n catalog per tab, as this repo's host has done since #788: every Inertia visit sends `X-SM-I18n`. The logic moved from `host/client_app/i18n.ts` into `@simple-module-py/i18n/inertia` (`bootI18nFromInitialPage`, `subscribeI18nToNavigation(router)`), and the scaffold's `client_app/app.tsx` uses it. An existing app can replace its `router.on('success')` catalog handler with those two calls; without the header the server still falls back to the per-session markers (#871).
- `host/alembic.ini`, both this repo's and the one `smpy new` scaffolds, sets `path_separator = os`. Alembic then splits `prepend_sys_path` and `version_locations` on the OS path separator. Without the setting, an app that adds either option gets a `DeprecationWarning` on every migration command, and a checkout path with a space is split in two. An existing app on Alembic 1.16 or newer can add the same line under `[alembic]` (#872).
- The `simple-module-creating` and `simple-module-conventions` skills, which ship inside `simple_module_cli`, linked the module layout standard as `../../docs/module-layout.md`. That path does not exist in an installed CLI. They now link https://docs.py.simplemodule.dev/module-layout, and a test fails if a shipped skill links a file outside `skills/`. `docs/module-layout.md` gains a "Downstream modules" section on copying this repo's root-layout test (`ALLOWED_ROOT`, `DOMAIN_AREAS`) into an app or a standalone module repo (#875).
- `smpy new` warns when the CLI running it is older than the latest `simple_module_cli` on PyPI. A new app pins the framework to the CLI's own version, and `uvx --from simple_module_cli` quietly reuses a copy installed with `uv tool install`, so an old tool used to scaffold an outdated app without saying so. The warning names both fixes: `uv tool upgrade simple_module_cli`, or `uvx --from simple_module_cli@latest`. The check is best effort, with a 3-second timeout; offline or on any PyPI error it is skipped, and it never blocks the scaffold. A CLI that is itself a pre-release, dev or local build is never warned. The README, the CLI and hosting READMEs and the landing page's copy command now use `uvx --from simple_module_cli@latest`, and the installation and quickstart guides, the docs home and the make-commands reference say how to upgrade an installed CLI, with `pipx upgrade` as the alternative (#899).
Expand Down
4 changes: 3 additions & 1 deletion docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,9 @@ The `LocaleMiddleware` resolves and the shared props carry the right bundle to t
## Catalog caching across tabs

Each tab sends `X-SM-I18n: <locale>:<audience>` on Inertia visits, describing the
catalog it actually holds. The `i18n` block includes `audience` (`public` or
catalog it actually holds. The client side ships in `@simple-module-py/i18n/inertia`
(`bootI18nFromInitialPage`, `subscribeI18nToNavigation(router)`); this repo's host
and every `smpy new` app use it. The `i18n` block includes `audience` (`public` or
`full`); messages are omitted only when the tab reports the current pair.
Full document loads always receive messages. Header-bearing requests never
write the legacy session markers. Clients without the header retain session
Expand Down
4 changes: 2 additions & 2 deletions docs/frontend/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,8 @@ returns goes into every Inertia response (`docs/frontend/shared-props.md`).
Keep providers cheap, and return callables for anything that costs a query,
so a partial reload that does not name them skips them. The framework's own (`auth`, `menus`,
`i18n`) come to about 3 KB, and the translation catalog is sent once per tab:
the client echoes the catalog it holds in `X-SM-I18n`, so later visits come
back without it.
the client echoes the catalog it holds in `X-SM-I18n` (wired by
`@simple-module-py/i18n/inertia`), so later visits come back without it.

### Link to the canonical URL

Expand Down
3 changes: 3 additions & 0 deletions docs/frontend/shared-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ t("orders.items", { count: orders.length }); // pluralization
Each tab advertises its cached locale/audience via `X-SM-I18n`. A matching
Inertia visit receives `messages: null`; otherwise the server resends the catalog.
The client reloads `i18n` once if it receives a changed locale without messages.
The client side is `bootI18nFromInitialPage` and `subscribeI18nToNavigation(router)` from
`@simple-module-py/i18n/inertia`; call both in `createInertiaApp`'s `setup()` (this repo's host
and `smpy new` apps do).

The `i18n.messages` block is the **resolved locale's** translations merged across all modules. Switching the locale (via `<LocaleSwitcher />`, which POSTs to `/i18n/set-locale`) triggers a full-page navigation so the messages are re-fetched. The active locale and supported locales are on `usePage().props.i18n` (`locale`, `supportedLocales`).

Expand Down
27 changes: 27 additions & 0 deletions docs/superpowers/specs/2026-11-11-i18n-client-sync-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Scaffolded apps send `X-SM-I18n` (#871)

#788 made the server choose whether to send the i18n catalog per tab, from the
`X-SM-I18n: <locale>:<audience>` header. Only this repo's `host/client_app/i18n.ts` sent
it. The scaffold's `app.tsx` had its own `router.on('success')` handler with no header,
so `smpy new` apps fell back to the per-session markers.

## Design

- Move the logic into `packages/i18n/src/inertia.ts`, exported as
`@simple-module-py/i18n/inertia` (a subpath export, so the main entry stays
framework-agnostic). It exports `bootI18nFromInitialPage(i18n)` and
`subscribeI18nToNavigation(router)`.
- The router is a parameter with a structural `I18nRouter` type, so the package does
not depend on `@inertiajs/react`.
- This repo's `host/client_app/app.tsx` and the scaffold template call it.
`host/client_app/i18n.ts` and its test are removed, and the test moves to
`packages/i18n/src/inertia.test.ts` with a fake router.
- Published apps pin `@simple-module-py/i18n` to the CLI version, which ships in
lockstep, so the subpath exists wherever the new template does.

## Verification

- The package tests (`vitest`), `make ci-js-typecheck` and Biome pass.
- In a scaffolded app with the new template and package: `tsc` passes. In the browser,
clicking a sidebar link sends `x-sm-i18n: en:full`, and the response carries
`"messages": null`, so the tab keeps the catalog it holds.
36 changes: 10 additions & 26 deletions framework/cli/simple_module_cli/templates/host/client_app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createInertiaApp, router } from '@inertiajs/react';
import { configureI18n, updateI18n } from '@simple-module-py/i18n';
import {
bootI18nFromInitialPage,
type I18nPageBlock,
subscribeI18nToNavigation,
} from '@simple-module-py/i18n/inertia';
import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title';
import { resolveServerHead } from '@simple-module-py/ui/lib/server-head';
import {
Expand All @@ -11,12 +15,6 @@ import { initTheme } from '@simple-module-py/ui/lib/theme';
import { createRoot } from 'react-dom/client';
import { resolvePage } from './pages';

type I18nBlock = {
locale: string;
supportedLocales?: string[];
messages: Record<string, string> | null;
};

createInertiaApp({
title: (title) => formatTitle(title),
// Re-emits the `_head` prop (server-rendered SEO tags) on client visits.
Expand All @@ -26,30 +24,16 @@ createInertiaApp({
},
setup({ el, App, props }) {
const pageProps = props.initialPage.props as {
i18n?: I18nBlock;
i18n?: I18nPageBlock;
branding?: { appName?: string | null };
};
setTitleAppName(pageProps.branding?.appName);
// Before the first render, so a dark-theme user never sees a light frame.
initTheme();
const initial = pageProps.i18n;
configureI18n({
locale: initial?.locale ?? 'en',
messages: initial?.messages ?? {},
});
router.on('success', (event) => {
const block = (event.detail.page.props as { i18n?: I18nBlock }).i18n;
// A non-null `messages` payload IS the server's signal that the client
// needs it — the backend sends `null` whenever the catalog the client
// already holds is still good. Gating on a locale change instead drops
// the catalog that arrives when the *audience* changes: signing in swaps
// the anonymous snapshot for one including admin-only modules, at the
// same locale, so every admin screen rendered raw keys
// ("dashboard.home.title") until a hard refresh.
if (block?.messages) {
updateI18n({ locale: block.locale, messages: block.messages });
}
});
// Reports this tab's catalog in `X-SM-I18n` on every visit and adopts the
// catalog the server sends back, so each tab keeps its own (#788, #871).
bootI18nFromInitialPage(pageProps.i18n);
subscribeI18nToNavigation(router);
// Authored content (pagebuilder widgets, markdown and rich-text fields)
// renders author-entered URLs as plain <a href>, which the browser would
// follow with a full document load. This app is client-rendered, so that
Expand Down
21 changes: 10 additions & 11 deletions framework/cli/tests/test_cli_new_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,23 +213,22 @@ def test_sm_new_rejects_mixed_separators(tmp_path: Path) -> None:
assert result.exit_code != 0


def test_scaffold_adopts_every_locale_catalog_the_server_sends(tmp_path: Path) -> None:
"""Issue #321: the scaffold gated ``updateI18n`` on a locale *change*, which
drops the catalog that arrives when the audience changes instead. Signing in
swaps the anonymous snapshot for one including admin-only modules at the same
locale, so every admin screen rendered raw keys ("dashboard.home.title")
until a hard refresh. A non-null ``messages`` payload is itself the server's
signal that the client needs it."""
def test_scaffold_wires_the_shared_i18n_catalog_sync(tmp_path: Path) -> None:
"""The scaffold uses ``@simple-module-py/i18n/inertia`` (GH #871), which sends
``X-SM-I18n`` per tab (#788) and adopts every non-null catalog the server
sends. A same-locale catalog after sign-in is covered there
(``packages/i18n/src/inertia.test.ts``, #321), so the scaffold must not keep
a handler of its own that could gate it on a locale change again."""
from simple_module_cli.scaffolding import create_host

dest = tmp_path / "demo"
create_host(dest, name="demo-host", modules=["Dashboard", "Auth"])

app_tsx = (dest / "client_app" / "app.tsx").read_text()
assert "updateI18n" in app_tsx
assert "activeLocale" not in app_tsx, (
"the locale gate is back — a same-locale catalog would be dropped again"
)
assert "from '@simple-module-py/i18n/inertia'" in app_tsx
assert "bootI18nFromInitialPage(" in app_tsx
assert "subscribeI18nToNavigation(router)" in app_tsx
assert "router.on('success'" not in app_tsx


def test_sm_new_host_app_tsx_wires_theme_and_title(tmp_path: Path) -> None:
Expand Down
6 changes: 3 additions & 3 deletions host/client_app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createInertiaApp, router } from '@inertiajs/react';
import { bootI18nFromInitialPage, subscribeI18nToNavigation } from '@simple-module-py/i18n/inertia';
import { ErrorBoundary } from '@simple-module-py/ui/components/ErrorBoundary';
import { OfflineBanner } from '@simple-module-py/ui/components/OfflineBanner';
import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title';
Expand All @@ -11,7 +12,6 @@ import {
import { initTheme } from '@simple-module-py/ui/lib/theme';
import { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n';
import { resolvePage } from './pages';

// The configured app name is held in the app-title module (Inertia's title
Expand All @@ -31,7 +31,7 @@ createInertiaApp({
const branding = (props.initialPage.props as { branding?: { appName?: string | null } })
.branding;
setTitleAppName(branding?.appName);
bootI18nFromInitialPage(props.initialPage.props);
bootI18nFromInitialPage(props.initialPage.props.i18n);
// Before the first render, so a dark-theme user never sees a light frame
// flash. The returned unsubscribe is deliberately dropped: the listener
// keeps `system` following the OS for the life of the document.
Expand All @@ -42,7 +42,7 @@ createInertiaApp({

useEffect(() => {
const stopReset = router.on('navigate', () => boundaryRef.current?.reset());
const stopI18n = subscribeI18nToNavigation();
const stopI18n = subscribeI18nToNavigation(router);
// Authored content renders author-entered URLs as plain <a href>, which
// the browser follows with a full document load — a blank page until
// this client-rendered app boots again. Route them through Inertia.
Expand Down
58 changes: 0 additions & 58 deletions host/client_app/i18n.ts

This file was deleted.

14 changes: 8 additions & 6 deletions packages/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,23 @@ Peer-depends on React 19. Runtime deps `i18next` and `react-i18next` install aut
- `useT()` — re-exported `react-i18next` `useTranslation` hook; returns `{ t }`.
- `t` — non-hook translation accessor (re-exported from `i18next`) for use in schemas or utilities.
- `keys` — generated, typed key tree (`keys.<module>.<...>`) you pass to `t()`.
- `@simple-module-py/i18n/inertia` — `bootI18nFromInitialPage(i18n)` and `subscribeI18nToNavigation(router)`: keep each tab's catalog in step with the server. Every Inertia visit reports the catalog the tab holds in `X-SM-I18n`, adopts the catalog the server sends back, and reloads `i18n` once when the locale changed without one.

## Usage

Root setup (in `client_app`):

```tsx
import { configureI18n, updateI18n } from "@simple-module-py/i18n";
import { router } from "@inertiajs/react";
import { bootI18nFromInitialPage, subscribeI18nToNavigation } from "@simple-module-py/i18n/inertia";

// On boot, from Inertia shared props (provided by InertiaLayoutDataMiddleware):
configureI18n({ locale: props.i18n.locale, messages: props.i18n.messages ?? {} });

// On every Inertia navigation that brings a new locale:
updateI18n({ locale: nextLocale, messages: nextMessages });
// In createInertiaApp's setup(), from the initial page's shared props:
bootI18nFromInitialPage(props.initialPage.props.i18n);
subscribeI18nToNavigation(router);
```

`configureI18n` / `updateI18n` remain for an app that wires navigation itself.

In a module page:

```tsx
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./inertia": {
"types": "./src/inertia.ts",
"default": "./src/inertia.ts"
}
},
"files": [
Expand Down
33 changes: 14 additions & 19 deletions host/client_app/i18n.test.ts → packages/i18n/src/inertia.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, expect, it, vi } from 'vitest';

const { handlers, reload, language, update } = vi.hoisted(() => ({
handlers: {} as {
const { handlers, reload, language, update, router } = vi.hoisted(() => {
const handlers = {} as {
before: (event: { detail: { visit: { headers: Record<string, string> } } }) => void;
success: (event: {
detail: {
Expand All @@ -16,35 +16,30 @@ const { handlers, reload, language, update } = vi.hoisted(() => ({
};
};
}) => void;
},
reload: vi.fn(),
language: { value: 'en' },
update: vi.fn(),
}));
vi.mock('@inertiajs/react', () => ({
router: {
on: vi.fn((name, handler) => {
};
const reload = vi.fn();
const router = {
on: vi.fn((name: 'before' | 'success', handler: never) => {
handlers[name] = handler;
return vi.fn();
}),
reload,
},
}));
vi.mock('@simple-module-py/i18n', () => ({
};
return { handlers, reload, language: { value: 'en' }, update: vi.fn(), router };
});
vi.mock('./index', () => ({
configureI18n: vi.fn(),
activeLocale: () => language.value,
updateI18n: update,
}));

import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n';
import { bootI18nFromInitialPage, type I18nRouter, subscribeI18nToNavigation } from './inertia';

beforeEach(() => {
vi.clearAllMocks();
language.value = 'en';
bootI18nFromInitialPage({
i18n: { locale: 'en', audience: 'public', supportedLocales: ['en'], messages: {} },
});
subscribeI18nToNavigation();
bootI18nFromInitialPage({ locale: 'en', audience: 'public', messages: {} });
subscribeI18nToNavigation(router as unknown as I18nRouter);
});

it('sends the catalog held by this tab and updates its audience after login', () => {
Expand Down Expand Up @@ -96,7 +91,7 @@ it('resets the retry guard after receiving a catalog', () => {
});

it('forces catalog delivery on recovery when the initial page had no audience', () => {
bootI18nFromInitialPage({});
bootI18nFromInitialPage(undefined);
const visit = { headers: {} as Record<string, string> };
handlers.before({ detail: { visit } });
expect(visit.headers).toEqual({});
Expand Down
Loading
Loading