Skip to content

Commit 42bccdb

Browse files
authored
Merge branch 'main' into fix/inertia-json-encoder-and-cache
2 parents ea167e1 + c198c2a commit 42bccdb

30 files changed

Lines changed: 466 additions & 1028 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ All notable changes to this project are documented in this file. The format is b
2828
production-mode containers pass `UsersSettings` boot validation.
2929

3030
### Fixed
31+
- Public pages no longer reload the whole document when a visitor clicks a link
32+
in authored content. A simple_module app is client-rendered — the root
33+
template ships `<div id="app"></div>` empty — so a navigation that creates a
34+
new document paints a blank white body until the bundle has booted. Admin
35+
screens were never affected because the shell navigates with Inertia's
36+
`<Link>`, but pagebuilder widgets and their markdown/rich-text fields render
37+
author-entered URLs as plain `<a href>`, and there is no component to swap for
38+
a `<Link>` when the anchor comes out of a markdown parser. A downstream site
39+
measured ~330ms of blank viewport per click on a throttled connection.
40+
`@simple-module-py/ui` now exports `startSpaLinkInterception()`, a delegated
41+
click handler that routes same-origin page links through Inertia whatever
42+
produced the anchor; the `smpy new` app template calls it. It deliberately
43+
leaves alone anything Inertia cannot render — other origins, non-http schemes,
44+
paths that look like a file, in-page anchors, `download`/`target`/
45+
`rel="external"`/`data-native-link`, and anchors inside a Puck editor surface
46+
— and falls back to a hard navigation if a visit returns without an
47+
`x-inertia` header, so a media download can never be replaced by an error
48+
modal. **Existing apps must add the one-line call to their own
49+
`host/client_app/app.tsx`**, which is scaffold output and so is not upgraded
50+
for them.
3151
- `smpy gen-pages` now emits module stylesheet `@import` lines as **absolute
3252
paths** instead of `#module/<pkg>` alias specifiers. The alias only resolved
3353
if the host's `vite.config.ts` defined a matching `resolve.alias` — but that

‎docs/frontend/inertia.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,55 @@ createInertiaApp({
149149

150150
`resolvePage` lives in `host/client_app/pages.ts` (hand-written). It builds a page-key → loader map from `moduleGlobs` (the `import.meta.glob` calls in the generated `modules.generated.ts`) plus the host's own `./pages/**/*.tsx` glob, mapping a page key (e.g. `"Orders/Browse"`) to a dynamic import of the matching `.tsx` file.
151151

152+
153+
## Link navigation
154+
155+
The app is client-rendered: the root template ships `<div id="app"></div>` empty
156+
and `app.tsx` fills it. So a navigation that creates a **new document** shows a
157+
blank white page until the bundle has booted — which is what a plain
158+
`<a href="/somewhere">` does.
159+
160+
Admin screens use Inertia's `<Link>` and are fine. The problem is authored
161+
content: pagebuilder widgets, and the markdown and rich-text fields inside them,
162+
turn author-entered URLs into plain anchors. An anchor that comes out of a
163+
markdown parser has no component to swap for a `<Link>`.
164+
165+
`app.tsx` therefore calls `startSpaLinkInterception()` once, before the first
166+
render:
167+
168+
```tsx
169+
import { startSpaLinkInterception } from "@simple-module-py/ui/lib/spa-links";
170+
171+
setup({ el, App, props }) {
172+
startSpaLinkInterception();
173+
createRoot(el).render(<App {...props} />);
174+
}
175+
```
176+
177+
It delegates one `click` listener on the document and routes same-origin page
178+
links through `router.visit()`, whatever produced the anchor. It runs in the
179+
bubble phase, so a `<Link>` — which cancels the event itself — is already
180+
`defaultPrevented` and is left alone rather than visited twice.
181+
182+
The bias is towards **not** interfering. These keep the browser's own behaviour:
183+
184+
| Left alone | Why |
185+
| --- | --- |
186+
| Another origin, or a non-http scheme (`mailto:`, `tel:`) | Not ours to route |
187+
| A path whose last segment looks like a file (`/media/report.pdf`) | Inertia would request it expecting a page and raise its error modal over the download |
188+
| `#`, `#section`, or a link to the current path | The browser scrolls correctly; `href="#"` is also what an unfilled pagebuilder nav row holds |
189+
| `download`, `target` other than `_self`, `rel="external"` | The author asked for it |
190+
| `data-native-link` | Explicit opt-out for anything the rules miss |
191+
| Anything inside `[data-puck-preview]` / `[data-puck-component]` | A Puck editor rendering without an iframe puts the edited page's real anchors in the admin document |
192+
| Modified clicks (⌘/ctrl/shift/alt) and non-left buttons | Open-in-new-tab must keep working |
193+
194+
As a backstop, a visit that comes back **without** an `x-inertia` header is
195+
handed to the browser as a hard navigation, so a URL that turns out not to be a
196+
page still resolves instead of raising the error modal.
197+
198+
`smpy new` wires this into the app template. An app scaffolded before this
199+
landed owns its own `app.tsx` and must add the call itself.
200+
152201
## CSRF
153202

154203
There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie:

‎docs/modules/branding.md‎

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# branding
22

3-
White-labels the application. An administrator sets the **app name**, **logo** (plus an optional dark-background variant), **favicon**, **primary colour**, **[design pack](/framework-conventions#design-packs-site-wide-look)**, a site-wide **announcement banner**, and a **configurable footer** — from an admin page, with no code change or redeploy.
3+
White-labels the application. An administrator sets the **app name**, **logo** (plus an optional dark-background variant), **favicon**, **primary colour**, **[design pack](/framework-conventions#design-packs-site-wide-look)**, and a site-wide **announcement banner** — from an admin page, with no code change or redeploy.
44

5-
Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page — authenticated *and* guest — through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, apply the brand colour, and show the banner and footer everywhere.
5+
Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page — authenticated *and* guest — through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, apply the brand colour, and show the banner everywhere. Footer content is owned by the framework site layouts, not branding.
66

77
## ModuleMeta
88

@@ -27,16 +27,14 @@ Every JSON endpoint — including the reads — requires `branding.manage`; they
2727
| `GET /api/branding/` | → `BrandingOut` |
2828
| `PUT /api/branding/` | `BrandingUpdate` → `BrandingOut` |
2929
| `POST /api/branding/presets/{key}` | → `BrandingOut` (`404` for an unknown key) |
30-
| `GET /api/branding/footer` | → `FooterConfig` |
31-
| `PUT /api/branding/footer` | `FooterConfig` → `FooterConfig` (whole-object replace) |
3230
| `POST /api/branding/logo` | `multipart` (field `file`) → `BrandingOut` |
3331
| `POST /api/branding/logo-dark` | `multipart` (field `file`) → `BrandingOut` |
3432
| `POST /api/branding/favicon` | `multipart` (field `file`) → `BrandingOut` |
3533
| `DELETE /api/branding/logo` | → `BrandingOut` (logo cleared) |
3634
| `DELETE /api/branding/logo-dark` | → `BrandingOut` (dark logo cleared) |
3735
| `DELETE /api/branding/favicon` | → `BrandingOut` (favicon cleared) |
3836

39-
`PUT /` only touches the text fields (`app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`); images are set and cleared through their dedicated upload/delete routes, and the footer through `PUT /footer`. A `design_pack` slug that no installed module registered is rejected with `422` — accepting it would put `"<slug>-root"` on the document with no stylesheet behind it, so the site would look unchanged with nothing in the UI explaining why.
37+
`PUT /` only touches the text fields (`app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`); images are set and cleared through their dedicated upload/delete routes. A `design_pack` slug that no installed module registered is rejected with `422` — accepting it would put `"<slug>-root"` on the document with no stylesheet behind it, so the site would look unchanged with nothing in the UI explaining why.
4038

4139
Uploads are validated **before** the bytes reach `file_storage`: an unsupported or unconvincing type returns `415`, an oversized image `413` (see [Image guard-rails](#image-guard-rails)).
4240

@@ -79,16 +77,12 @@ An unversioned URL can serve new bytes later, so it must never be immutable; the
7977

8078
```python
8179
from branding.contracts import BrandingOut, BrandingUpdate
82-
from branding.contracts.footer import FooterConfig, FooterColumn, FooterLink
8380
```
8481

8582
| Class | Purpose |
8683
|---|---|
8784
| `BrandingOut` | Current branding with images resolved to URLs: `app_name`, `primary_color`, `design_pack`, `logo_url`, `logo_dark_url`, `favicon_url`, `banner_message`, `banner_severity`. |
8885
| `BrandingUpdate` | Editable text fields, all optional: `app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`. |
89-
| `FooterConfig` | The whole footer: `tagline`, `copyright_owner`, `note`, `columns`, `social_links`. |
90-
| `FooterColumn` | A titled group: `title` + `links`. |
91-
| `FooterLink` | One `label` + `href`. |
9286

9387
`BrandingUpdate` is the strict one. An unknown `banner_severity` is a clear `422` here, while the settings validator normalises it to `info` — settings hydrate from the DB, where a hand-edited row must degrade to a readable banner rather than stop the app from booting. `design_pack` and `primary_color` are shape-checked in the DTO for the same reason: a malformed value becomes a `422` instead of a `500` when `BrandingSettings` re-validates.
9488

@@ -110,16 +104,9 @@ DB-backed via `register_module_settings`; pydantic defaults seed at boot. Edited
110104
| `favicon_file_id` | `""` | UUID of the favicon; `""` ⇒ no custom favicon. |
111105
| `banner_message` | `""` | Site-wide announcement text (≤ 500 chars); `""` ⇒ no banner. |
112106
| `banner_severity` | `"info"` | One of `info`, `warning`, `danger`. Unknown values normalise to `info`. |
113-
| `footer_tagline` | `""` | Footer tagline line. |
114-
| `footer_copyright_owner` | `""` | Name in the copyright line. |
115-
| `footer_note` | `""` | Small footer caption. |
116-
| `footer_columns` | `""` | JSON blob of `FooterColumn` entries (see [Configurable footer](#configurable-footer)). |
117-
| `footer_social_links` | `""` | JSON blob of `FooterLink` entries. |
118107

119108
`app_name` rejects control characters, not just blanks: the name is used in HTML titles and — critically — email `Subject` headers, where an embedded CR/LF would survive a bare `strip()` and then raise, breaking every transactional email.
120109

121-
The two footer lists are strings because the settings store holds strings; `branding.footer` owns (de)serialising them, leniently, so a malformed row degrades to "no configured footer" rather than an error. Each blob is capped at 8 000 characters so a single setting row can't grow unbounded.
122-
123110
### Image guard-rails
124111

125112
Enforced in the API before the upload reaches `file_storage`:
@@ -160,23 +147,6 @@ A message plus a severity, rendered above every shell — app, public and auth
160147

161148
Severity colours are semantic, not brand-tinted: a warning wearing the deployment's accent colour stops reading as a warning.
162149

163-
## Configurable footer
164-
165-
Whole-object replace via `PUT /api/branding/footer` — a partial merge into nested link lists has no obvious semantics.
166-
167-
| Limit | Value |
168-
|---|---|
169-
| Columns | 6 |
170-
| Links per column | 8 |
171-
| Social links | 8 |
172-
| Link label / column title | 40 chars |
173-
| Link URL | 500 chars |
174-
| Tagline / copyright / note | 200 chars |
175-
176-
Link URLs are restricted to `http(s)` or a single-leading-slash app path. This is the security-relevant part: these are authored by an admin and rendered into an anchor on *every* page, including guest ones, so the scheme allow-list is what keeps a `javascript:` URL out of the document. `//host` is a protocol-relative absolute URL rather than a path, so it falls through to the scheme check and is rejected.
177-
178-
With nothing configured the shared prop reports `footer: null` and the framework's built-in footer renders unchanged.
179-
180150
## Dark-background logo
181151

182152
The sidebar and mobile bar sit on a near-black surface in every theme, while the sign-in card and public page are light — so a single logo cannot read on both. Uploading a *Logo (dark backgrounds)* variant swaps it in on those surfaces only.
@@ -196,19 +166,12 @@ On startup the module registers a shared-props provider (`register_inertia_share
196166
"logoUrl": "/api/branding/logo?v=<file id>",
197167
"logoDarkUrl": "/api/branding/logo-dark?v=<file id>",
198168
"faviconUrl": "/api/branding/favicon?v=<file id>",
199-
"banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" },
200-
"footer": {
201-
"tagline": "Maps for everyone",
202-
"copyrightOwner": "Acme Corp",
203-
"note": "",
204-
"columns": [{ "title": "Product", "links": [{ "label": "Docs", "href": "/docs" }] }],
205-
"socialLinks": [{ "label": "GitHub", "href": "https://github.com/acme" }]
206-
}
169+
"banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" }
207170
}
208171
}
209172
```
210173

211-
`primaryColor` and `designPack` are `null` when unset; the three image URLs are `null` when no file is configured. `banner` is `null` when no message is set, so the frontend renders nothing at all rather than an empty bar, and `footer` is `null` when nothing is configured, so the built-in framework footer stays.
174+
`primaryColor` and `designPack` are `null` when unset; the three image URLs are `null` when no file is configured. `banner` is `null` when no message is set, so the frontend renders nothing at all rather than an empty bar.
212175

213176
The provider is defensive — it returns `{}` if branding state isn't mounted yet, so a half-booted app never errors a render. Because changes go through the settings store, a save hot-reloads `app.state.branding.settings`; the next render reflects the new values without a restart.
214177

@@ -217,7 +180,7 @@ The provider is defensive — it returns `{}` if branding state isn't mounted ye
217180
| Code | Granted to | Purpose |
218181
|---|---|---|
219182
| `branding.view` | `admin` | open the Branding admin page (`/branding`) |
220-
| `branding.manage` | `admin` | read + write branding via the API (name, colour, design pack, banner, footer, image upload/clear) |
183+
| `branding.manage` | `admin` | read + write branding via the API (name, colour, design pack, banner, image upload/clear) |
221184

222185
## Menu
223186

@@ -227,7 +190,7 @@ The provider is defensive — it returns `{}` if branding state isn't mounted ye
227190

228191
## Inertia pages
229192

230-
- `Branding/Manage.tsx` — the admin editor: app name, colour and design pack, preset picker, logo / dark logo / favicon upload and clear, banner editor, footer editor, and a live preview.
193+
- `Branding/Manage.tsx` — the admin editor: app name, colour and design pack, preset picker, logo / dark logo / favicon upload and clear, banner editor, and a live preview.
231194

232195
## Locales
233196

‎docs/modules/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ simple_module_python ships with twelve first-party modules. Each is a regular Py
1111
| [`settings`](/modules/settings) | — | DB-backed key/value store with system / tenant / user precedence; per-module pydantic settings registration; hot reload; `smpy settings` CLI. |
1212
| [`feature_flags`](/modules/feature_flags) | — | Runtime feature toggles with system + tenant overrides. |
1313
| [`file_storage`](/modules/file_storage) | `settings` | Pluggable file storage (filesystem, S3-compatible) with upload validation, presigned URLs, browse/download/delete UI. |
14-
| [`branding`](/modules/branding) | `settings`, `file_storage` | White-labelling — app name, logo (plus a dark-background variant), favicon, primary colour, design pack, announcement banner and configurable footer, pushed to every page via Inertia shared props. Serves the logo and favicon from anonymous routes so guests see the brand too. |
14+
| [`branding`](/modules/branding) | `settings`, `file_storage` | White-labelling — app name, logo (plus a dark-background variant), favicon, primary colour, design pack and announcement banner, pushed to every page via Inertia shared props. Serves the logo and favicon from anonymous routes so guests see the brand too. |
1515
| [`background_tasks`](/modules/background_tasks) | `users` | Celery + Redis workers, persistent task history, retry, stuck-task sweep, live worker dashboard. |
1616
| [`audit_log`](/modules/audit_log) | `users` | Automatic field-level audit trail for SQLModel entities, with an admin UI to browse change history. |
1717
| [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). |

‎framework/cli/simple_module_cli/templates/host/client_app/app.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createInertiaApp, router } from '@inertiajs/react';
22
import { configureI18n, updateI18n } from '@simple-module-py/i18n';
3+
import { startSpaLinkInterception } from '@simple-module-py/ui/lib/spa-links';
34
import { createRoot } from 'react-dom/client';
45
import { resolvePage } from './pages';
56

@@ -28,6 +29,11 @@ createInertiaApp({
2829
activeLocale = block.locale;
2930
}
3031
});
32+
// Authored content (pagebuilder widgets, markdown and rich-text fields)
33+
// renders author-entered URLs as plain <a href>, which the browser would
34+
// follow with a full document load. This app is client-rendered, so that
35+
// means a blank page until the bundle boots. Route them through Inertia.
36+
startSpaLinkInterception();
3137
createRoot(el).render(<App {...props} />);
3238
},
3339
progress: {

‎host/client_app/app.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createInertiaApp, router } from '@inertiajs/react';
22
import { ErrorBoundary } from '@simple-module-py/ui/components/ErrorBoundary';
33
import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title';
4+
import { startSpaLinkInterception } from '@simple-module-py/ui/lib/spa-links';
45
import { useEffect, useRef } from 'react';
56
import { createRoot } from 'react-dom/client';
67
import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n';
@@ -29,9 +30,14 @@ createInertiaApp({
2930
useEffect(() => {
3031
const stopReset = router.on('navigate', () => boundaryRef.current?.reset());
3132
const stopI18n = subscribeI18nToNavigation();
33+
// Authored content renders author-entered URLs as plain <a href>, which
34+
// the browser follows with a full document load — a blank page until
35+
// this client-rendered app boots again. Route them through Inertia.
36+
const stopLinks = startSpaLinkInterception();
3237
return () => {
3338
stopReset();
3439
stopI18n();
40+
stopLinks();
3541
};
3642
}, []);
3743

‎modules/branding/README.md‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,9 @@ modules to be installed too.
4343

4444
Programmatically, the current branding is available on every page through the
4545
`branding` Inertia shared prop (`appName`, `primaryColor`, `designPack`,
46-
`logoUrl`, `logoDarkUrl`, `faviconUrl`, `banner`, `footer`). `banner` and
47-
`footer` are `null` when unconfigured, which is what makes the frontend fall
48-
back to rendering nothing and to the framework footer respectively. For a dark
49-
surface use
46+
`logoUrl`, `logoDarkUrl`, `faviconUrl`, `banner`). `banner` is `null` when
47+
unconfigured, which makes the frontend render nothing. Footer content is owned
48+
by the framework site layouts rather than branding. For a dark surface use
5049
`darkSurfaceLogo(branding)` from `@simple-module-py/ui/lib/brand`, which applies
5150
the `logoDarkUrl → logoUrl` fallback in one place.
5251

@@ -78,12 +77,6 @@ the `logoDarkUrl → logoUrl` fallback in one place.
7877
ever sets *appearance* (`PRESET_FIELDS` — primary colour, design pack); it
7978
can never overwrite the app name, an uploaded logo or a live banner, and
8079
`BrandingPreset` rejects any other field at construction.
81-
- **Configurable footer.** Tagline, copyright owner, caption, up to 6 columns
82-
of 8 links, and up to 8 social links (`PUT /api/branding/footer`, whole-object
83-
replace). Link URLs are restricted to http(s) or a single-leading-slash app
84-
path — `javascript:` and `data:` are refused, and `//host` is treated as the
85-
off-site absolute URL it is rather than a path. With nothing configured the
86-
framework's built-in footer renders unchanged.
8780
- **Dark-background logo.** The sidebar and mobile bar sit on a near-black
8881
surface in every theme, while the sign-in card and public page are light — so
8982
a single logo cannot read on both. Uploading a *Logo (dark backgrounds)*

0 commit comments

Comments
 (0)