simple-module-hosting 0.0.26__tar.gz → 0.0.27__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.27}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/PKG-INFO +3 -3
  3. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/pyproject.toml +3 -3
  4. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/_inertia_setup.py +12 -7
  5. simple_module_hosting-0.0.27/simple_module_hosting/assets.py +160 -0
  6. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/host_cli.py +2 -2
  7. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/manifest.py +34 -27
  8. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_branding_head.py +19 -2
  9. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_middleware_order.py +9 -1
  10. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/LICENSE +0 -0
  11. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/README.md +0 -0
  12. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/__init__.py +0 -0
  13. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/__main__.py +0 -0
  14. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/_error_handlers.py +0 -0
  15. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/_host_services.py +0 -0
  16. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/_hydrate_step.py +0 -0
  17. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/_inertia_shared.py +0 -0
  18. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/_observability.py +0 -0
  19. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/_phase_helpers.py +0 -0
  20. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/app_builder.py +0 -0
  21. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/bootstrap_settings.py +0 -0
  22. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/health.py +0 -0
  23. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/host_settings.py +0 -0
  24. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/i18n_deps.py +0 -0
  25. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/i18n_manifest.py +0 -0
  26. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/i18n_middleware.py +0 -0
  27. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/inertia_deps.py +0 -0
  28. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/inertia_utils.py +0 -0
  29. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/logging.py +0 -0
  30. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/middleware.py +0 -0
  31. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/migrations.py +0 -0
  32. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/permissions.py +0 -0
  33. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/py.typed +0 -0
  34. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/redirects.py +0 -0
  35. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/settings.py +0 -0
  36. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/shared_props.py +0 -0
  37. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/simple_module_hosting/static_files.py +0 -0
  38. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_app.py +0 -0
  39. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_check_migrations.py +0 -0
  40. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_design_packs_wiring.py +0 -0
  41. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_error_page_shared_props.py +0 -0
  42. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_health.py +0 -0
  43. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_host_cli.py +0 -0
  44. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_hosting_permissions.py +0 -0
  45. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_i18n_manifest.py +0 -0
  46. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_inertia_i18n_shared_props.py +0 -0
  47. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_inertia_manifest.py +0 -0
  48. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_inertia_shared_providers.py +0 -0
  49. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_lifespan_order.py +0 -0
  50. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_locale_middleware.py +0 -0
  51. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_logging.py +0 -0
  52. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_manifest.py +0 -0
  53. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_menu_urls_are_canonical.py +0 -0
  54. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_precompressed_static.py +0 -0
  55. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_proxy_headers.py +0 -0
  56. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_redirects.py +0 -0
  57. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_response_compression.py +0 -0
  58. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_session_cookie_security.py +0 -0
  59. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_settings_i18n.py +0 -0
  60. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_settings_secrets.py +0 -0
  61. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_static_caching.py +0 -0
  62. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_strict_discovery_wiring.py +0 -0
  63. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/tests/test_tenant_middleware.py +0 -0
  64. {simple_module_hosting-0.0.26 → simple_module_hosting-0.0.27}/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.27
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
@@ -26,8 +26,8 @@ Requires-Dist: fastapi-inertia>=1.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: jinja2>=3.1
29
- Requires-Dist: simple-module-core==0.0.26
30
- Requires-Dist: simple-module-db==0.0.26
29
+ Requires-Dist: simple-module-core==0.0.27
30
+ Requires-Dist: simple-module-db==0.0.27
31
31
  Requires-Dist: starlette>=0.44
32
32
  Requires-Dist: tomlkit>=0.13
33
33
  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.27"
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"
@@ -26,8 +26,8 @@ dependencies = [
26
26
  "fastapi-inertia>=1.0",
27
27
  "httpx>=0.27",
28
28
  "jinja2>=3.1",
29
- "simple_module_core==0.0.26",
30
- "simple_module_db==0.0.26",
29
+ "simple_module_core==0.0.27",
30
+ "simple_module_db==0.0.27",
31
31
  "starlette>=0.44",
32
32
  "tomlkit>=0.13",
33
33
  "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
 
@@ -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
 
@@ -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:
@@ -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
@@ -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",