simple-module-hosting 0.0.26__tar.gz → 0.0.28__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/PKG-INFO +4 -3
  3. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/pyproject.toml +7 -3
  4. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/_inertia_setup.py +12 -7
  5. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/_inertia_shared.py +20 -5
  6. simple_module_hosting-0.0.28/simple_module_hosting/assets.py +160 -0
  7. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/host_cli.py +2 -2
  8. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/i18n_manifest.py +2 -1
  9. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/manifest.py +34 -27
  10. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/middleware.py +1 -1
  11. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_branding_head.py +19 -2
  12. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_inertia_i18n_shared_props.py +83 -0
  13. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_middleware_order.py +9 -1
  14. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/LICENSE +0 -0
  15. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/README.md +0 -0
  16. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/__init__.py +0 -0
  17. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/__main__.py +0 -0
  18. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/_error_handlers.py +0 -0
  19. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/_host_services.py +0 -0
  20. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/_hydrate_step.py +0 -0
  21. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/_observability.py +0 -0
  22. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/_phase_helpers.py +0 -0
  23. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/app_builder.py +0 -0
  24. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/bootstrap_settings.py +0 -0
  25. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/health.py +0 -0
  26. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/host_settings.py +0 -0
  27. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/i18n_deps.py +0 -0
  28. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/i18n_middleware.py +0 -0
  29. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/inertia_deps.py +0 -0
  30. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/inertia_utils.py +0 -0
  31. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/logging.py +0 -0
  32. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/migrations.py +0 -0
  33. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/permissions.py +0 -0
  34. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/py.typed +0 -0
  35. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/redirects.py +0 -0
  36. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/settings.py +0 -0
  37. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/shared_props.py +0 -0
  38. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/simple_module_hosting/static_files.py +0 -0
  39. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_app.py +0 -0
  40. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_check_migrations.py +0 -0
  41. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_design_packs_wiring.py +0 -0
  42. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_error_page_shared_props.py +0 -0
  43. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_health.py +0 -0
  44. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_host_cli.py +0 -0
  45. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_hosting_permissions.py +0 -0
  46. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_i18n_manifest.py +0 -0
  47. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_inertia_manifest.py +0 -0
  48. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_inertia_shared_providers.py +0 -0
  49. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_lifespan_order.py +0 -0
  50. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_locale_middleware.py +0 -0
  51. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_logging.py +0 -0
  52. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_manifest.py +0 -0
  53. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_menu_urls_are_canonical.py +0 -0
  54. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_precompressed_static.py +0 -0
  55. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_proxy_headers.py +0 -0
  56. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_redirects.py +0 -0
  57. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_response_compression.py +0 -0
  58. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_session_cookie_security.py +0 -0
  59. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_settings_i18n.py +0 -0
  60. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_settings_secrets.py +0 -0
  61. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_static_caching.py +0 -0
  62. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_strict_discovery_wiring.py +0 -0
  63. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_tenant_middleware.py +0 -0
  64. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.28}/tests/test_translator_dep.py +0 -0
@@ -45,6 +45,7 @@ docs/.vitepress/dist/
45
45
  host/client_app/modules.manifest.json
46
46
  host/client_app/modules.generated.ts
47
47
  host/client_app/modules.generated.css
48
+ host/client_app/modules.assets.json
48
49
 
49
50
  # Worktrees
50
51
  .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_hosting
3
- Version: 0.0.26
3
+ Version: 0.0.28
4
4
  Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -25,9 +25,10 @@ Requires-Dist: click>=8.1
25
25
  Requires-Dist: fastapi-inertia>=1.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
+ Requires-Dist: itsdangerous>=2.0
28
29
  Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.26
30
- Requires-Dist: simple-module-db==0.0.26
30
+ Requires-Dist: simple-module-core==0.0.28
31
+ Requires-Dist: simple-module-db==0.0.28
31
32
  Requires-Dist: starlette>=0.44
32
33
  Requires-Dist: tomlkit>=0.13
33
34
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.26"
3
+ version = "0.0.28"
4
4
  description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -25,9 +25,13 @@ dependencies = [
25
25
  "fastapi>=0.115",
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
+ # SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
29
+ # module import time; starlette treats it as an optional extra, so declare
30
+ # it directly — out-of-tree hosts have nothing else pulling it in.
31
+ "itsdangerous>=2.0",
28
32
  "jinja2>=3.1",
29
- "simple_module_core==0.0.26",
30
- "simple_module_db==0.0.26",
33
+ "simple_module_core==0.0.28",
34
+ "simple_module_db==0.0.28",
31
35
  "starlette>=0.44",
32
36
  "tomlkit>=0.13",
33
37
  "uvicorn[standard]>=0.34",
@@ -30,20 +30,25 @@ def branding_head(request: Request) -> dict:
30
30
  """Branding metadata for the root template's ``<head>``.
31
31
 
32
32
  Reads the optional branding module's settings off ``app.state`` by name
33
- (duck-typed, never imported) so the static ``<title>`` and ``theme-color``
34
- are already branded *before* React hydrates. Degrades to the framework
35
- default when branding isn't installed. Only plain settings strings are
36
- surfaced here — the favicon is applied client-side by ``BrandingHead`` via
37
- Inertia's ``<Head>``, keeping file_storage's download-route shape out of
38
- framework code.
33
+ (duck-typed, never imported) so the ``<title>``, ``theme-color`` and
34
+ favicon are already branded *before* React hydrates — otherwise the browser
35
+ paints the default favicon and only swaps on hydration, a visible flicker on
36
+ every full page load. Degrades to the framework default when branding isn't
37
+ installed.
38
+
39
+ The favicon URL is read from the module rather than assembled here: branding
40
+ owns its route shape, and framework code must not reach into a plugin
41
+ (SM009). ``BrandingHead`` still applies it client-side too, so a favicon
42
+ changed at runtime updates without a reload.
39
43
  """
40
44
  services = getattr(request.app.state, "branding", None)
41
45
  settings = getattr(services, "settings", None)
42
46
  if settings is None:
43
- return {"app_name": _DEFAULT_APP_NAME, "theme_color": None}
47
+ return {"app_name": _DEFAULT_APP_NAME, "theme_color": None, "favicon_url": None}
44
48
  return {
45
49
  "app_name": getattr(settings, "app_name", "") or _DEFAULT_APP_NAME,
46
50
  "theme_color": getattr(settings, "primary_color", "") or None,
51
+ "favicon_url": getattr(services, "favicon_url", None),
47
52
  }
48
53
 
49
54
 
@@ -12,20 +12,25 @@ from starlette.types import Scope
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
  _I18N_SESSION_LOCALE_KEY = "__i18n_locale"
15
+ _I18N_SESSION_AUDIENCE_KEY = "__i18n_audience"
15
16
  _INERTIA_HEADER = "x-inertia"
16
17
  _INERTIA_HEADER_TRUE = "true"
17
18
 
18
19
 
19
- def build_i18n_block(scope: Scope, request: Request) -> dict:
20
+ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = True) -> dict:
20
21
  """Assemble the ``i18n`` shared-props block for the current request.
21
22
 
22
23
  Rules:
23
24
 
24
25
  * No registry / no locale → serve an empty English block and log once.
25
26
  * Inertia XHR partials (``X-Inertia: true``) reuse the client-side
26
- cached messages; send ``messages: None`` unless the locale differs
27
- from what was last served on this session.
27
+ cached messages; send ``messages: None`` unless the locale — or the
28
+ audience, see below — differs from what was last served this session.
28
29
  * Full page loads and locale transitions ship the complete dict.
30
+ * Anonymous visitors receive the public snapshot: catalogs of modules
31
+ declaring ``i18n_audience="admin"`` are withheld (issue #248). A login
32
+ or logout mid-session counts as a change so the freshly-authenticated
33
+ client isn't left holding the anonymous catalog (or vice versa).
29
34
  """
30
35
  # Test fixtures sometimes build a bare FastAPI with a partial app.state.sm
31
36
  # stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable.
@@ -42,6 +47,7 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
42
47
  return {"locale": "en", "supportedLocales": ["en"], "messages": {}}
43
48
 
44
49
  is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE
50
+ audience = "full" if is_authenticated else "public"
45
51
  session_dict = scope.get("session")
46
52
  # When the session is absent (pre-session-middleware routes, WebSocket
47
53
  # upgrades), treat locale as "unchanged" so Inertia XHR requests still
@@ -52,13 +58,22 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
52
58
  locale_changed = last_locale != locale
53
59
  if locale_changed:
54
60
  session_dict[_I18N_SESSION_LOCALE_KEY] = locale
61
+ last_audience = session_dict.get(_I18N_SESSION_AUDIENCE_KEY)
62
+ audience_changed = last_audience != audience
63
+ if audience_changed:
64
+ session_dict[_I18N_SESSION_AUDIENCE_KEY] = audience
55
65
  else:
56
66
  locale_changed = False
57
- send_messages = (not is_inertia) or locale_changed
67
+ audience_changed = False
68
+ send_messages = (not is_inertia) or locale_changed or audience_changed
58
69
  return {
59
70
  "locale": locale,
60
71
  "supportedLocales": registry.available_locales(),
61
- "messages": registry.messages_snapshot(locale) if send_messages else None,
72
+ "messages": (
73
+ registry.messages_snapshot(locale, include_admin=is_authenticated)
74
+ if send_messages
75
+ else None
76
+ ),
62
77
  }
63
78
 
64
79
 
@@ -0,0 +1,160 @@
1
+ """Per-module frontend asset discovery and CSS emission.
2
+
3
+ Split out from :mod:`simple_module_hosting.manifest`, which is already at the
4
+ repo's 300-line cap. ``manifest.py`` imports from here.
5
+
6
+ A module may ship two optional stylesheets beside its ``pages/`` directory:
7
+
8
+ * ``theme.css`` — ``@theme`` tokens, ``@custom-variant``, ``@font-face``.
9
+ Imported *unlayered*, because a ``@theme`` block inside a cascade layer is
10
+ inert and registers no design tokens.
11
+ * ``styles.css`` — component rules, keyframes, vendor CSS. Imported into
12
+ ``layer(components)``, because unlayered CSS outranks every Tailwind
13
+ utility: a module shipping a bare ``.card { padding: 0 }`` would otherwise
14
+ silently beat ``p-4``.
15
+
16
+ Both are referenced through a per-module Vite alias (``#module/<pkg>``) rather
17
+ than a filesystem path, so the generated CSS never contains something like
18
+ ``../../../.venv/lib/python3.12/site-packages/<pkg>/styles.css``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import importlib.resources
24
+ import json
25
+ import logging
26
+ from collections.abc import Callable, Sequence
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ from simple_module_core import ModuleBase, get_module_package_name
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ THEME_CSS = "theme.css"
35
+ STYLES_CSS = "styles.css"
36
+ ALIAS_PREFIX = "#module"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ModuleAssets:
41
+ """Frontend assets a single module contributes."""
42
+
43
+ name: str
44
+ package_name: str
45
+ package_dir: Path
46
+ pages_dir: Path | None
47
+ theme_css: Path | None
48
+ styles_css: Path | None
49
+
50
+
51
+ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]:
52
+ """Return per-module frontend assets, preserving discovery order.
53
+
54
+ Discovery order matters. ``discover_modules()`` topologically sorts by
55
+ ``ModuleMeta.depends_on``, which is the order the host invokes
56
+ ``register_*`` hooks in; emitting CSS in that same order means a module
57
+ that depends on another can override its dependency's styles.
58
+
59
+ A module is included only if it contributes at least one asset.
60
+ """
61
+ result: list[ModuleAssets] = []
62
+ for mod in modules:
63
+ pkg_name = get_module_package_name(mod)
64
+ try:
65
+ pkg_root = Path(str(importlib.resources.files(pkg_name)))
66
+ except (ModuleNotFoundError, TypeError):
67
+ logger.debug(
68
+ "Module '%s': package %s not importable — skipping", mod.meta.name, pkg_name
69
+ )
70
+ continue
71
+ pages_dir = pkg_root / "pages"
72
+ theme = pkg_root / THEME_CSS
73
+ styles = pkg_root / STYLES_CSS
74
+ entry = ModuleAssets(
75
+ name=mod.meta.name,
76
+ package_name=pkg_name,
77
+ package_dir=pkg_root.resolve(),
78
+ pages_dir=pages_dir.resolve() if pages_dir.is_dir() else None,
79
+ theme_css=theme.resolve() if theme.is_file() else None,
80
+ styles_css=styles.resolve() if styles.is_file() else None,
81
+ )
82
+ if entry.pages_dir or entry.theme_css or entry.styles_css:
83
+ result.append(entry)
84
+ return result
85
+
86
+
87
+ _CSS_HEADER = """\
88
+ /* AUTO-GENERATED by simple_module_hosting.assets — do not edit by hand.
89
+ * Regenerate with: smpy gen-pages
90
+ *
91
+ * Emission order is module discovery order (topological by depends_on), so a
92
+ * dependent module's CSS can override its dependency's.
93
+ *
94
+ * theme.css is imported unlayered so its @theme blocks register as design
95
+ * tokens; styles.css is imported into layer(components) so a module rule can
96
+ * never outrank a Tailwind utility.
97
+ */"""
98
+
99
+
100
+ def render_modules_css(
101
+ assets: Sequence[ModuleAssets],
102
+ *,
103
+ in_repo: Callable[[Path], bool],
104
+ ) -> str:
105
+ """Render the contents of ``modules.generated.css``.
106
+
107
+ ``@source`` is emitted only for wheel-installed modules — in-repo module
108
+ pages are already covered by the static ``@source`` glob in the host's
109
+ ``styles.css``, so emitting an absolute one too would just duplicate it.
110
+ ``@import`` is emitted for *every* module, in-repo and wheel alike, because
111
+ there is no static-glob equivalent for CSS.
112
+ """
113
+ source_lines = [
114
+ f'@source "{e.pages_dir.as_posix()}/**/*.{{ts,tsx}}";'
115
+ for e in assets
116
+ if e.pages_dir and not in_repo(e.pages_dir)
117
+ ]
118
+ theme_lines = [
119
+ f'@import "{ALIAS_PREFIX}/{e.package_name}/{THEME_CSS}";' for e in assets if e.theme_css
120
+ ]
121
+ style_lines = [
122
+ f'@import "{ALIAS_PREFIX}/{e.package_name}/{STYLES_CSS}" layer(components);'
123
+ for e in assets
124
+ if e.styles_css
125
+ ]
126
+
127
+ out = [_CSS_HEADER]
128
+ for heading, lines in (
129
+ ("/* ── @source: class scanning (wheel-installed modules) ── */", source_lines),
130
+ ("/* ── theme: unlayered, registers @theme tokens ── */", theme_lines),
131
+ ("/* ── styles: layer(components), always loses to utilities ── */", style_lines),
132
+ ):
133
+ if lines:
134
+ out.append("")
135
+ out.append(heading)
136
+ out.extend(lines)
137
+ out.append("")
138
+ return "\n".join(out)
139
+
140
+
141
+ def render_assets_json(assets: Sequence[ModuleAssets]) -> str:
142
+ """Render ``modules.assets.json`` — the richer companion to the pages manifest.
143
+
144
+ Kept separate from ``modules.manifest.json`` rather than replacing it:
145
+ ``vite.config.ts`` is scaffolded *into* each app, so changing the existing
146
+ manifest's value shape would break every downstream app at once. This file
147
+ is purely additive, and also covers CSS-only modules that never appear in
148
+ the pages manifest because they ship no ``pages/``.
149
+ """
150
+ payload = {
151
+ e.name: {
152
+ "package_name": e.package_name,
153
+ "package": e.package_dir.as_posix(),
154
+ "pages": e.pages_dir.as_posix() if e.pages_dir else None,
155
+ "theme": e.theme_css.as_posix() if e.theme_css else None,
156
+ "styles": e.styles_css.as_posix() if e.styles_css else None,
157
+ }
158
+ for e in assets
159
+ }
160
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
@@ -37,7 +37,7 @@ def gen_pages(
37
37
  ),
38
38
  ] = Path("client_app"),
39
39
  ) -> None:
40
- """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}."""
40
+ """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css,assets.json}."""
41
41
  logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
42
42
  if not host_dir.is_dir():
43
43
  typer.echo(f"ERROR: client_app directory not found at {host_dir}", err=True)
@@ -47,7 +47,7 @@ def gen_pages(
47
47
  typer.echo(
48
48
  f"Module pages manifest: {len(modules)} module(s) "
49
49
  f"→ {written['manifest'].name}, {written['generated'].name}, "
50
- f"{written['css'].name} in {host_dir}"
50
+ f"{written['css'].name}, {written['assets'].name} in {host_dir}"
51
51
  )
52
52
 
53
53
 
@@ -44,8 +44,9 @@ def build_i18n_registry(
44
44
  extra_sources: list[tuple[str, str, Path]] = []
45
45
 
46
46
  for mod in modules:
47
+ audience = getattr(mod.meta, "i18n_audience", "public")
47
48
  for namespace, locale_dir in mod.locale_dirs().items():
48
- registry.add_source(namespace, locale_dir)
49
+ registry.add_source(namespace, locale_dir, audience=audience)
49
50
 
50
51
  host_locales = project_root / "host" / "locales"
51
52
  if host_locales.is_dir():
@@ -1,10 +1,13 @@
1
1
  """Frontend pages manifest + per-module JS dependency discovery.
2
2
 
3
- * :func:`write_module_pages_manifest` emits the three generated files Vite
3
+ * :func:`write_module_pages_manifest` emits the four generated files Vite
4
4
  and Tailwind read at build time:
5
5
  - ``modules.manifest.json`` — name -> absolute pages dir
6
6
  - ``modules.generated.ts`` — ``import.meta.glob`` per module
7
- - ``modules.generated.css`` — ``@source`` per wheel-installed module
7
+ - ``modules.generated.css`` — ``@source`` per wheel-installed module, plus
8
+ an ``@import`` per module-shipped stylesheet
9
+ - ``modules.assets.json`` — the richer per-module asset record the host's
10
+ ``vite.config.ts`` builds its ``#module/<pkg>`` aliases from
8
11
  * :func:`read_module_package_json` / :func:`collect_module_js_deps` locate
9
12
  the per-module ``package.json`` shipped inside wheels (or alongside the
10
13
  source for editable installs) and aggregate its ``dependencies`` block.
@@ -22,6 +25,12 @@ from pathlib import Path
22
25
 
23
26
  from simple_module_core import ModuleBase, get_module_package_name
24
27
 
28
+ from simple_module_hosting.assets import (
29
+ compute_module_assets,
30
+ render_assets_json,
31
+ render_modules_css,
32
+ )
33
+
25
34
  logger = logging.getLogger(__name__)
26
35
 
27
36
  _GENERATED_TS_HEADER = """\
@@ -33,16 +42,6 @@ _GENERATED_TS_HEADER = """\
33
42
  // pip-installed module wheels are picked up by Vite's import.meta.glob.
34
43
  """
35
44
 
36
- _GENERATED_CSS_HEADER = """\
37
- /* AUTO-GENERATED by simple_module_hosting.manifest — do not edit by hand.
38
- * Regenerate with: smpy gen-pages
39
- *
40
- * @source entries for module pages shipped inside pip-installed wheels.
41
- * In-repo modules are covered by the static @source glob in
42
- * host/client_app/styles.css.
43
- */
44
- """
45
-
46
45
 
47
46
  def repo_root_from_client_app(client_app_dir: Path) -> Path:
48
47
  """Locate the workspace/repo root that contains ``client_app_dir``.
@@ -144,11 +143,16 @@ def write_module_pages_manifest(
144
143
  output_dir: Path,
145
144
  repo_root: Path | None = None,
146
145
  ) -> dict[str, Path]:
147
- """Write the three generated files (manifest JSON, glob TS, Tailwind CSS).
146
+ """Write the four generated files (manifest JSON, glob TS, CSS, assets JSON).
148
147
 
149
148
  Returns the paths that were written. Content is written only when
150
149
  different from what's on disk, so booting the host repeatedly in dev
151
150
  does not wake up Vite's file watcher.
151
+
152
+ ``modules.manifest.json`` deliberately keeps its ``{name: pages_dir}``
153
+ shape: every downstream app holds its own copy of ``vite.config.ts`` and
154
+ reads it, so changing the value shape would break them all at once. The
155
+ new per-module asset record goes in ``modules.assets.json`` instead.
152
156
  """
153
157
  output_dir = Path(output_dir)
154
158
  output_dir.mkdir(parents=True, exist_ok=True)
@@ -180,32 +184,35 @@ def write_module_pages_manifest(
180
184
  lines.append("")
181
185
  generated_text = "\n".join(lines)
182
186
 
183
- # In-repo modules are already covered by the static glob in styles.css;
184
- # only emit @source for wheel-installed ones. pages_map is keyed by
185
- # module name so each pages_dir is unique — no further dedup needed.
187
+ # Rendered from the richer asset record rather than pages_map, so that a
188
+ # module shipping CSS but no pages/ still contributes its stylesheets.
189
+ assets = compute_module_assets(modules)
186
190
  css_path = output_dir / "modules.generated.css"
187
- css_lines: list[str] = [_GENERATED_CSS_HEADER]
188
- for name in sorted(pages_map):
189
- pages_dir = pages_map[name]
190
- if _is_in_repo_module(pages_dir, repo_root):
191
- continue
192
- css_lines.append(f'@source "{pages_dir.as_posix()}";')
193
- css_lines.append("")
194
- css_text = "\n".join(css_lines)
191
+ css_text = render_modules_css(assets, in_repo=lambda p: _is_in_repo_module(p, repo_root))
192
+
193
+ assets_path = output_dir / "modules.assets.json"
194
+ assets_text = render_assets_json(assets)
195
195
 
196
196
  wrote_manifest = _write_if_changed(manifest_path, manifest_text)
197
197
  wrote_generated = _write_if_changed(generated_path, generated_text)
198
198
  wrote_css = _write_if_changed(css_path, css_text)
199
+ wrote_assets = _write_if_changed(assets_path, assets_text)
199
200
 
200
- if wrote_manifest or wrote_generated or wrote_css:
201
+ if wrote_manifest or wrote_generated or wrote_css or wrote_assets:
201
202
  logger.info(
202
- "Wrote module pages manifest: %d module(s) -> %s, %s, %s",
203
+ "Wrote module pages manifest: %d module(s) -> %s, %s, %s, %s",
203
204
  len(pages_map),
204
205
  manifest_path.name,
205
206
  generated_path.name,
206
207
  css_path.name,
208
+ assets_path.name,
207
209
  )
208
- return {"manifest": manifest_path, "generated": generated_path, "css": css_path}
210
+ return {
211
+ "manifest": manifest_path,
212
+ "generated": generated_path,
213
+ "css": css_path,
214
+ "assets": assets_path,
215
+ }
209
216
 
210
217
 
211
218
  def _glob_pattern_for(pages_dir: Path, output_dir: Path) -> str:
@@ -256,7 +256,7 @@ class InertiaLayoutDataMiddleware:
256
256
  all_perms = self.permission_registry.all_permissions
257
257
  frontend_permissions = expand_permissions(resolved, all_perms) if is_authenticated else []
258
258
 
259
- i18n_block = build_i18n_block(scope, request)
259
+ i18n_block = build_i18n_block(scope, request, is_authenticated=is_authenticated)
260
260
 
261
261
  principal_serializer: PrincipalSerializer | None = getattr(
262
262
  scope["app"].state, "principal_serializer", None
@@ -16,7 +16,7 @@ def _request(branding: object | None) -> SimpleNamespace:
16
16
 
17
17
  def test_defaults_when_branding_not_installed() -> None:
18
18
  meta = branding_head(_request(None))
19
- assert meta == {"app_name": "SimpleModule", "theme_color": None}
19
+ assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None}
20
20
 
21
21
 
22
22
  def test_reads_app_name_and_theme_color() -> None:
@@ -29,4 +29,21 @@ def test_reads_app_name_and_theme_color() -> None:
29
29
  def test_blank_values_fall_back() -> None:
30
30
  settings = SimpleNamespace(app_name="", primary_color="")
31
31
  meta = branding_head(_request(SimpleNamespace(settings=settings)))
32
- assert meta == {"app_name": "SimpleModule", "theme_color": None}
32
+ assert meta == {"app_name": "SimpleModule", "theme_color": None, "favicon_url": None}
33
+
34
+
35
+ def test_favicon_url_comes_from_the_module_not_from_here() -> None:
36
+ # The framework must not know branding's route shape (SM009), so it reads
37
+ # whatever the module exposes rather than assembling a URL itself.
38
+ services = SimpleNamespace(
39
+ settings=SimpleNamespace(app_name="Acme", primary_color=""),
40
+ favicon_url="/api/branding/favicon?v=abc",
41
+ )
42
+ assert branding_head(_request(services))["favicon_url"] == "/api/branding/favicon?v=abc"
43
+
44
+
45
+ def test_favicon_url_is_none_on_a_host_without_that_attribute() -> None:
46
+ # An older branding release exposes no favicon_url; the shell just omits
47
+ # the link rather than erroring, and BrandingHead still sets it client-side.
48
+ services = SimpleNamespace(settings=SimpleNamespace(app_name="Acme", primary_color=""))
49
+ assert branding_head(_request(services))["favicon_url"] is None
@@ -64,6 +64,89 @@ def test_inertia_shared_props_reflect_cookie_locale() -> None:
64
64
  assert body["i18n"]["messages"] == {"hello": "Hola"}
65
65
 
66
66
 
67
+ def _build_audience_app(tmp_path) -> FastAPI:
68
+ """App with a *loaded* registry (public + admin sources) and header-driven auth.
69
+
70
+ ``X-Test-Auth: 1`` marks the request authenticated, so one client session
71
+ can flip auth state mid-session — the login/logout transition the i18n
72
+ block must react to.
73
+ """
74
+ import json
75
+ from types import SimpleNamespace
76
+
77
+ for ns, data in (
78
+ ("pages", {"title": "Pages"}),
79
+ ("settings", {"key": "Key"}),
80
+ ):
81
+ (tmp_path / ns).mkdir(exist_ok=True)
82
+ (tmp_path / ns / "en.json").write_text(json.dumps(data))
83
+ reg = I18nRegistry(default_locale="en", supported_locales=["en"])
84
+ reg.add_source("pages", tmp_path / "pages")
85
+ reg.add_source("settings", tmp_path / "settings", audience="admin")
86
+ reg.load()
87
+
88
+ app = FastAPI()
89
+ app.state.sm = SimpleNamespace(i18n_registry=reg)
90
+
91
+ @app.get("/shared")
92
+ def shared(request: Request) -> JSONResponse:
93
+ return JSONResponse(request.state.inertia_shared)
94
+
95
+ app.add_middleware(
96
+ InertiaLayoutDataMiddleware,
97
+ menu_registry=MenuRegistry(),
98
+ permission_registry=PermissionRegistry(),
99
+ )
100
+ app.add_middleware(
101
+ LocaleMiddleware,
102
+ supported_locales=["en"],
103
+ default_locale="en",
104
+ )
105
+
106
+ class _HeaderAuth:
107
+ def __init__(self, app_):
108
+ self.app = app_
109
+
110
+ async def __call__(self, scope, receive, send):
111
+ if scope["type"] == "http":
112
+ request = Request(scope)
113
+ if request.headers.get("X-Test-Auth") == "1":
114
+ request.state.user = SimpleNamespace(roles=[])
115
+ await self.app(scope, receive, send)
116
+
117
+ app.add_middleware(_HeaderAuth)
118
+ app.add_middleware(SessionMiddleware, secret_key="test-secret")
119
+ return app
120
+
121
+
122
+ def test_anonymous_visitors_receive_only_public_catalogs(tmp_path) -> None:
123
+ client = TestClient(_build_audience_app(tmp_path))
124
+ body = client.get("/shared").json()
125
+ assert body["i18n"]["messages"] == {"pages.title": "Pages"}
126
+
127
+
128
+ def test_authenticated_users_receive_admin_catalogs_too(tmp_path) -> None:
129
+ client = TestClient(_build_audience_app(tmp_path))
130
+ body = client.get("/shared", headers={"X-Test-Auth": "1"}).json()
131
+ assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"}
132
+
133
+
134
+ def test_login_mid_session_reships_messages_on_an_inertia_partial(tmp_path) -> None:
135
+ """An Inertia partial normally skips messages — but not right after login,
136
+ or the freshly-authenticated client would keep the anonymous catalog."""
137
+ client = TestClient(_build_audience_app(tmp_path))
138
+ client.get("/shared") # anonymous full load seeds the session audience
139
+ body = client.get("/shared", headers={"X-Test-Auth": "1", "X-Inertia": "true"}).json()
140
+ assert body["i18n"]["messages"] == {"pages.title": "Pages", "settings.key": "Key"}
141
+
142
+
143
+ def test_inertia_partial_with_unchanged_audience_still_skips_messages(tmp_path) -> None:
144
+ client = TestClient(_build_audience_app(tmp_path))
145
+ client.get("/shared")
146
+ body = client.get("/shared", headers={"X-Inertia": "true"}).json()
147
+ assert body["i18n"]["messages"] is None
148
+
149
+
67
150
  def test_inertia_shared_props_fallback_when_registry_missing(
68
151
  caplog,
69
152
  ) -> None:
@@ -7,7 +7,13 @@ CLAUDE.md spells out the pipeline:
7
7
 
8
8
  Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so
9
9
  DB queries get filtered correctly; CorrelationId must wrap everything so
10
- every log line carries its id. GZip sits inside the observability pair so
10
+ every log line carries its id. SiteLock must precede AuthMiddleware: it gates
11
+ anonymous visitors itself, and if Auth ran first they would be redirected to
12
+ the login page — revealing that a login form exists on a site that is meant
13
+ to be fully hidden. That inversion breaks the feature without failing any
14
+ site_lock unit test, which is why the order is pinned here.
15
+
16
+ GZip sits inside the observability pair so
11
17
  those still see every request, but outside everything that produces a body
12
18
  — including the /static mount, which is where compression pays off most.
13
19
  Order matters and a swap is the kind of
@@ -28,6 +34,7 @@ _EXPECTED_MULTI_TENANT = (
28
34
  "GZipMiddleware",
29
35
  "SecurityHeadersMiddleware",
30
36
  "SessionMiddleware",
37
+ "SiteLockMiddleware",
31
38
  "AuthMiddleware",
32
39
  "TenantMiddleware",
33
40
  "LocaleMiddleware",
@@ -40,6 +47,7 @@ _EXPECTED_SINGLE_TENANT = (
40
47
  "GZipMiddleware",
41
48
  "SecurityHeadersMiddleware",
42
49
  "SessionMiddleware",
50
+ "SiteLockMiddleware",
43
51
  "AuthMiddleware",
44
52
  "LocaleMiddleware",
45
53
  "InertiaLayoutDataMiddleware",