simple-module-hosting 0.0.25__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.25 → simple_module_hosting-0.0.27}/.gitignore +1 -0
  2. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/PKG-INFO +3 -3
  3. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/pyproject.toml +3 -3
  4. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/_inertia_setup.py +12 -7
  5. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/_phase_helpers.py +28 -0
  6. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/app_builder.py +12 -22
  7. simple_module_hosting-0.0.27/simple_module_hosting/assets.py +160 -0
  8. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/host_cli.py +2 -2
  9. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/manifest.py +34 -27
  10. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_branding_head.py +19 -2
  11. simple_module_hosting-0.0.27/tests/test_design_packs_wiring.py +52 -0
  12. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_middleware_order.py +9 -1
  13. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/LICENSE +0 -0
  14. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/README.md +0 -0
  15. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/__init__.py +0 -0
  16. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/__main__.py +0 -0
  17. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/_error_handlers.py +0 -0
  18. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/_host_services.py +0 -0
  19. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/_hydrate_step.py +0 -0
  20. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/_inertia_shared.py +0 -0
  21. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/_observability.py +0 -0
  22. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/bootstrap_settings.py +0 -0
  23. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/health.py +0 -0
  24. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/host_settings.py +0 -0
  25. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/i18n_deps.py +0 -0
  26. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/i18n_manifest.py +0 -0
  27. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/i18n_middleware.py +0 -0
  28. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/inertia_deps.py +0 -0
  29. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/inertia_utils.py +0 -0
  30. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/logging.py +0 -0
  31. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/middleware.py +0 -0
  32. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/migrations.py +0 -0
  33. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/permissions.py +0 -0
  34. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/py.typed +0 -0
  35. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/redirects.py +0 -0
  36. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/settings.py +0 -0
  37. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/shared_props.py +0 -0
  38. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/simple_module_hosting/static_files.py +0 -0
  39. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_app.py +0 -0
  40. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_check_migrations.py +0 -0
  41. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_error_page_shared_props.py +0 -0
  42. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_health.py +0 -0
  43. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_host_cli.py +0 -0
  44. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_hosting_permissions.py +0 -0
  45. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_i18n_manifest.py +0 -0
  46. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_inertia_i18n_shared_props.py +0 -0
  47. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_inertia_manifest.py +0 -0
  48. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_inertia_shared_providers.py +0 -0
  49. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_lifespan_order.py +0 -0
  50. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_locale_middleware.py +0 -0
  51. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_logging.py +0 -0
  52. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_manifest.py +0 -0
  53. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_menu_urls_are_canonical.py +0 -0
  54. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_precompressed_static.py +0 -0
  55. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_proxy_headers.py +0 -0
  56. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_redirects.py +0 -0
  57. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_response_compression.py +0 -0
  58. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_session_cookie_security.py +0 -0
  59. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_settings_i18n.py +0 -0
  60. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_settings_secrets.py +0 -0
  61. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_static_caching.py +0 -0
  62. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_strict_discovery_wiring.py +0 -0
  63. {simple_module_hosting-0.0.25 → simple_module_hosting-0.0.27}/tests/test_tenant_middleware.py +0 -0
  64. {simple_module_hosting-0.0.25 → 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.25
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.25
30
- Requires-Dist: simple-module-db==0.0.25
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.25"
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.25",
30
- "simple_module_db==0.0.25",
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
 
@@ -32,6 +32,8 @@ from simple_module_hosting._error_handlers import (
32
32
  request_validation_error_handler,
33
33
  unhandled_exception_handler,
34
34
  )
35
+ from simple_module_hosting._host_services import _HostServices
36
+ from simple_module_hosting.host_settings import HostSettings
35
37
  from simple_module_hosting.i18n_middleware import LocaleMiddleware
36
38
  from simple_module_hosting.middleware import (
37
39
  CorrelationIdMiddleware,
@@ -165,6 +167,32 @@ def mount_module_static_dirs(app: FastAPI, modules: list) -> None:
165
167
  )
166
168
 
167
169
 
170
+ def register_host_settings(app: FastAPI) -> None:
171
+ """Register host-level settings under ``package="host"`` (DB-backed).
172
+
173
+ The Settings module must already have run ``register_settings`` — topo
174
+ order puts it early, since its ``meta.depends_on`` is empty. When the
175
+ Settings module isn't enabled there's no registry to register against, so
176
+ this skips quietly.
177
+
178
+ ``settings.registration`` is resolved via importlib rather than a plain
179
+ ``from settings.registration import ...``: the SM009 coupling check is
180
+ AST-based and forbids any static import of a plugin package name from
181
+ within ``framework/*``. Dynamic resolution keeps the framework AST
182
+ plugin-free while still hitting the real helper at runtime.
183
+ """
184
+ if not hasattr(app.state, "settings"):
185
+ return
186
+
187
+ import importlib
188
+
189
+ register_module_settings = importlib.import_module(
190
+ "settings.registration"
191
+ ).register_module_settings
192
+
193
+ register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
194
+
195
+
168
196
  def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]:
169
197
  """SM012: warn if a module overrides register_settings but added nothing to app.state.
170
198
 
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
10
10
  from pathlib import Path
11
11
 
12
12
  from fastapi import FastAPI
13
+ from simple_module_core.design_packs import DesignPackRegistry
13
14
  from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
14
15
  from simple_module_core.discovery import discover_modules, topological_sort
15
16
  from simple_module_core.events import EventBus
@@ -22,7 +23,6 @@ from simple_module_core.services import Services
22
23
  from simple_module_db.listeners import register_listeners
23
24
  from simple_module_db.session import init_db
24
25
 
25
- from simple_module_hosting._host_services import _HostServices
26
26
  from simple_module_hosting._inertia_setup import setup_inertia
27
27
  from simple_module_hosting._phase_helpers import (
28
28
  attach_public_routes,
@@ -30,10 +30,10 @@ from simple_module_hosting._phase_helpers import (
30
30
  install_middleware,
31
31
  mount_module_static_dirs,
32
32
  register_exception_handlers,
33
+ register_host_settings,
33
34
  wire_module_routes,
34
35
  )
35
36
  from simple_module_hosting.health import router as health_router
36
- from simple_module_hosting.host_settings import HostSettings
37
37
  from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
38
38
  from simple_module_hosting.migrations import check_migrations
39
39
  from simple_module_hosting.settings import Settings
@@ -165,6 +165,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
165
165
  event_bus = EventBus()
166
166
  health_registry = HealthRegistry()
167
167
  public_route_registry = PublicRouteRegistry()
168
+ design_pack_registry = DesignPackRegistry()
168
169
 
169
170
  @asynccontextmanager
170
171
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
@@ -203,25 +204,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
203
204
  for mod in modules:
204
205
  mod.register_settings(app)
205
206
 
206
- # Register host-level settings under package="host" (DB-backed). The
207
- # Settings module must already have run register_settings (topo order
208
- # puts it early; its meta.depends_on = [] so it's among the first).
209
- # When the Settings module isn't enabled, there's no registry to
210
- # register against — skip quietly.
211
- #
212
- # We resolve `settings.registration` via importlib rather than a plain
213
- # `from settings.registration import ...`: the SM009 coupling check is
214
- # AST-based and forbids any static import of a plugin package name
215
- # from within framework/* code. Dynamic resolution keeps the framework
216
- # AST plugin-free while still hitting the real helper at runtime.
217
- if hasattr(app.state, "settings"):
218
- import importlib
219
-
220
- _register_module_settings = importlib.import_module(
221
- "settings.registration"
222
- ).register_module_settings
223
-
224
- _register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
207
+ register_host_settings(app)
225
208
 
226
209
  if settings.is_development:
227
210
  settings_diagnostics = check_settings_registration(app, modules)
@@ -236,17 +219,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
236
219
  _register_event_handlers(mod, event_bus, app)
237
220
  mod.register_health_checks(health_registry)
238
221
  mod.register_public_routes(public_route_registry)
222
+ mod.register_design_packs(design_pack_registry)
239
223
 
240
224
  attach_public_routes(app, settings, public_route_registry)
241
225
 
226
+ # Branding reads the packs off app.state directly: its API validates a
227
+ # submitted slug before persisting it, and its view builds the dropdown.
228
+ app.state.design_packs = design_pack_registry
229
+
242
230
  logger.info(
243
231
  "Registered %d menu items, %d permissions, %d feature flags, "
244
- "%d health checks, %d public routes",
232
+ "%d health checks, %d public routes, %d design packs",
245
233
  len(menu_registry.all_items),
246
234
  len(perm_registry.all_permissions),
247
235
  len(ff_registry.all_flags),
248
236
  len(health_registry.all_checks),
249
237
  len(public_route_registry.routes),
238
+ len(design_pack_registry.all()),
250
239
  )
251
240
 
252
241
  # ── Phase 6: Initialize database ───────────────────────
@@ -291,6 +280,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
291
280
  feature_flags=ff_registry,
292
281
  health_registry=health_registry,
293
282
  public_routes=public_route_registry,
283
+ design_packs=design_pack_registry,
294
284
  i18n_registry=i18n_registry,
295
285
  inertia_config=inertia_config,
296
286
  modules=tuple(modules),
@@ -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
@@ -0,0 +1,52 @@
1
+ """``create_app`` must collect module design packs onto ``app.state.design_packs``.
2
+
3
+ Branding reads this registry twice — the view builds its dropdown from it, and
4
+ the API validates a submitted slug against it. If the hook were never called,
5
+ or the populated registry never reached ``app.state``, branding would offer an
6
+ empty dropdown and reject every pack, with nothing in the logs to explain why.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import simple_module_hosting.app_builder as app_builder
12
+ from simple_module_core.design_packs import DesignPack, DesignPackRegistry
13
+ from simple_module_core.module import ModuleBase, ModuleMeta
14
+ from simple_module_hosting.app_builder import create_app
15
+
16
+ _PACK = DesignPack(value="pack-fixture", label="Pack Fixture")
17
+
18
+
19
+ class _PackModule(ModuleBase):
20
+ """A module whose only contribution is one design pack."""
21
+
22
+ meta = ModuleMeta(name="PackFixture")
23
+
24
+ def register_design_packs(self, registry: DesignPackRegistry) -> None:
25
+ registry.register(_PACK)
26
+
27
+
28
+ def test_registry_is_published_on_app_state(settings):
29
+ app = create_app(settings)
30
+ assert isinstance(app.state.design_packs, DesignPackRegistry)
31
+
32
+
33
+ def test_module_registered_pack_reaches_app_state(monkeypatch, settings):
34
+ real_discover = app_builder.discover_modules
35
+
36
+ def _with_pack_module(*args, **kwargs):
37
+ return [*real_discover(*args, **kwargs), _PackModule()]
38
+
39
+ monkeypatch.setattr(app_builder, "discover_modules", _with_pack_module)
40
+
41
+ app = create_app(settings)
42
+
43
+ assert app.state.design_packs.has("pack-fixture")
44
+ assert _PACK in app.state.design_packs.all()
45
+
46
+
47
+ def test_registry_is_also_reachable_through_services(settings):
48
+ # ``app.state.sm`` is the aggregate every other registry is published on;
49
+ # design packs should not be the one exception a module author has to
50
+ # remember a different lookup for.
51
+ app = create_app(settings)
52
+ assert app.state.sm.design_packs is app.state.design_packs
@@ -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",