You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 42bccdb
Browse filesBrowse the repository at this point in the historyBrowse files
Copy file name to clipboardExpand all lines: docs/frontend/inertia.md
+49Lines changed: 49 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -149,6 +149,55 @@ createInertiaApp({
149
149
150
150
`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.
151
151
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
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
+
152
201
## CSRF
153
202
154
203
There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie:
Copy file name to clipboardExpand all lines: docs/modules/branding.md
+7-44Lines changed: 7 additions & 44 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,8 +1,8 @@
1
1
# branding
2
2
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.
4
4
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.
6
6
7
7
## ModuleMeta
8
8
@@ -27,16 +27,14 @@ Every JSON endpoint — including the reads — requires `branding.manage`; they
`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.
40
38
41
39
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)).
42
40
@@ -79,16 +77,12 @@ An unversioned URL can serve new bytes later, so it must never be immutable; the
79
77
80
78
```python
81
79
from branding.contracts import BrandingOut, BrandingUpdate
82
-
from branding.contracts.footer import FooterConfig, FooterColumn, FooterLink
83
80
```
84
81
85
82
| Class | Purpose |
86
83
|---|---|
87
84
|`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`. |
88
85
|`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`. |
92
86
93
87
`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.
94
88
@@ -110,16 +104,9 @@ DB-backed via `register_module_settings`; pydantic defaults seed at boot. Edited
110
104
|`favicon_file_id`|`""`| UUID of the favicon; `""` ⇒ no custom favicon. |
111
105
|`banner_message`|`""`| Site-wide announcement text (≤ 500 chars); `""` ⇒ no banner. |
112
106
|`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. |
118
107
119
108
`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.
120
109
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
-
123
110
### Image guard-rails
124
111
125
112
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
160
147
161
148
Severity colours are semantic, not brand-tinted: a warning wearing the deployment's accent colour stops reading as a warning.
162
149
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
-
180
150
## Dark-background logo
181
151
182
152
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
"banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" }
207
170
}
208
171
}
209
172
```
210
173
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.
212
175
213
176
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.
214
177
@@ -217,7 +180,7 @@ The provider is defensive — it returns `{}` if branding state isn't mounted ye
217
180
| Code | Granted to | Purpose |
218
181
|---|---|---|
219
182
|`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) |
221
184
222
185
## Menu
223
186
@@ -227,7 +190,7 @@ The provider is defensive — it returns `{}` if branding state isn't mounted ye
227
190
228
191
## Inertia pages
229
192
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.
Copy file name to clipboardExpand all lines: docs/modules/index.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -11,7 +11,7 @@ simple_module_python ships with twelve first-party modules. Each is a regular Py
11
11
|[`settings`](/modules/settings)| — | DB-backed key/value store with system / tenant / user precedence; per-module pydantic settings registration; hot reload; `smpy settings` CLI. |
12
12
|[`feature_flags`](/modules/feature_flags)| — | Runtime feature toggles with system + tenant overrides. |
|[`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 packand announcement banner, pushed to every page via Inertia shared props. Serves the logo and favicon from anonymous routes so guests see the brand too. |
0 commit comments