From 0a0be3a408cc96bfc7b746b1cad2b0d0e520b363 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 9 Aug 2026 16:12:59 +0200 Subject: [PATCH] feat(hosting): source wheel modules' components/ in gen-pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_modules_css` emitted an absolute `@source` for each wheel module's `pages/` but not its `components/`, so widgets shipped in a wheel (pagebuilder's SiteHeader, MediaObject, Hero, ...) had their Tailwind classes dropped from the build unless the host hand-added a `@source` line. Hand-sourcing a wheel dir can only be written as a `.venv`-relative path, which silently matches nothing on Windows (`.venv/Lib/site-packages`, not `.venv/lib/python3.x/...`) — the site builds with the widget nav at `display:none` and no error. Discover `components/` alongside `pages/` and emit the same absolute `as_posix()` `@source` glob. In-repo modules stay covered by the host's static `modules/*` glob. No host hand-writes a `.venv/...` line again. Closes #258 Claude-Session: https://claude.ai/code/session_01852vDTaepS9fbFaMEWrrVX --- framework/cli/tests/test_module_css_assets.py | 58 ++++++++++++++++++- .../hosting/simple_module_hosting/assets.py | 26 +++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/framework/cli/tests/test_module_css_assets.py b/framework/cli/tests/test_module_css_assets.py index c608dfd1..ea4d4875 100644 --- a/framework/cli/tests/test_module_css_assets.py +++ b/framework/cli/tests/test_module_css_assets.py @@ -50,6 +50,33 @@ async def test_detects_theme_and_styles(self, tmp_path): assert entry.styles_css is not None and entry.styles_css.name == "styles.css" assert entry.pages_dir is not None + async def test_detects_components_dir(self, tmp_path): + """A module shipping components/ has it detected — its widgets carry + Tailwind classes the build has to scan just like a page's.""" + from simple_module_hosting.assets import compute_module_assets + + mod, pkg = _make_importable_module(tmp_path, "widget_mod", "Widgets") + (pkg / "components").mkdir() + + result = compute_module_assets([mod]) + + assert len(result) == 1 + assert result[0].components_dir is not None + assert result[0].components_dir.name == "components" + + async def test_components_only_module_is_included(self, tmp_path): + """A module with only components/ (no pages/, no css) still appears.""" + from simple_module_hosting.assets import compute_module_assets + + mod, pkg = _make_importable_module(tmp_path, "conly_mod", "ComponentsOnly") + (pkg / "components").mkdir() + + result = compute_module_assets([mod]) + + assert [e.name for e in result] == ["ComponentsOnly"] + assert result[0].components_dir is not None + assert result[0].pages_dir is None + async def test_css_only_module_is_included(self, tmp_path): """A module with CSS but no pages/ still appears — the manifest.json gap.""" from simple_module_hosting.assets import compute_module_assets @@ -94,6 +121,7 @@ def _assets(tmp_path: Path, **overrides): "package_name": "gis", "package_dir": tmp_path / "gis", "pages_dir": None, + "components_dir": None, "theme_css": None, "styles_css": None, } @@ -142,6 +170,34 @@ async def test_source_emitted_for_wheel_modules(self, tmp_path): assert f'@source "{pages.as_posix()}/**/*.{{ts,tsx}}";' in css + async def test_components_source_emitted_for_wheel_modules(self, tmp_path): + """A wheel module's components/ gets its own absolute @source glob. + + This is the class scanning a widget's ``lg:flex`` depends on. Emitting + the resolved path via ``as_posix`` (not a hand-written + ``.venv/lib/python3.x/...`` line) is what keeps the build working on a + Windows ``.venv/Lib/site-packages`` layout too. + """ + from simple_module_hosting.assets import render_modules_css + + components = tmp_path / "gis" / "components" + css = render_modules_css( + [_assets(tmp_path, components_dir=components)], in_repo=lambda _p: False + ) + + assert f'@source "{components.as_posix()}/**/*.{{ts,tsx}}";' in css + + async def test_components_source_skips_in_repo(self, tmp_path): + """In-repo module components are already covered by the host's static glob.""" + from simple_module_hosting.assets import render_modules_css + + css = render_modules_css( + [_assets(tmp_path, components_dir=tmp_path / "gis" / "components")], + in_repo=lambda _p: True, + ) + + assert "@source" not in css + async def test_module_without_css_emits_no_import(self, tmp_path): """Pages-only modules contribute @source but no @import.""" from simple_module_hosting.assets import render_modules_css @@ -203,7 +259,7 @@ async def test_writes_assets_json(self, tmp_path): entry = data["Dashboard"] assert entry["package_name"] == "dashboard" assert entry["package"].endswith("dashboard") - assert set(entry) == {"package_name", "package", "pages", "theme", "styles"} + assert set(entry) == {"package_name", "package", "pages", "components", "theme", "styles"} async def test_manifest_json_shape_unchanged(self, tmp_path): """modules.manifest.json stays {name: pages_dir} for downstream vite configs. diff --git a/framework/hosting/simple_module_hosting/assets.py b/framework/hosting/simple_module_hosting/assets.py index c2b9e50e..7da4ec81 100644 --- a/framework/hosting/simple_module_hosting/assets.py +++ b/framework/hosting/simple_module_hosting/assets.py @@ -44,6 +44,7 @@ class ModuleAssets: package_name: str package_dir: Path pages_dir: Path | None + components_dir: Path | None theme_css: Path | None styles_css: Path | None @@ -69,6 +70,7 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: ) continue pages_dir = pkg_root / "pages" + components_dir = pkg_root / "components" theme = pkg_root / THEME_CSS styles = pkg_root / STYLES_CSS entry = ModuleAssets( @@ -76,10 +78,11 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]: package_name=pkg_name, package_dir=pkg_root.resolve(), pages_dir=pages_dir.resolve() if pages_dir.is_dir() else None, + components_dir=components_dir.resolve() if components_dir.is_dir() else None, theme_css=theme.resolve() if theme.is_file() else None, styles_css=styles.resolve() if styles.is_file() else None, ) - if entry.pages_dir or entry.theme_css or entry.styles_css: + if entry.pages_dir or entry.components_dir or entry.theme_css or entry.styles_css: result.append(entry) return result @@ -104,16 +107,26 @@ def render_modules_css( ) -> str: """Render the contents of ``modules.generated.css``. - ``@source`` is emitted only for wheel-installed modules — in-repo module - pages are already covered by the static ``@source`` glob in the host's - ``styles.css``, so emitting an absolute one too would just duplicate it. + ``@source`` is emitted for a wheel-installed module's ``pages/`` **and** + ``components/`` — both hold ``.tsx`` carrying Tailwind classes (a widget's + ``lg:flex`` is as real as a page's), and neither is covered by the host's + static glob, which only reaches in-repo ``modules/*``. In-repo modules are + skipped here because that static glob already covers them; emitting an + absolute path too would just duplicate it. + + These are absolute ``as_posix()`` paths, so the same generated file works on + POSIX and Windows — unlike a hand-written ``.venv/lib/python3.x/...`` line, + which silently matches nothing on a Windows ``.venv/Lib/site-packages`` + layout and drops every widget class from the build. + ``@import`` is emitted for *every* module, in-repo and wheel alike, because there is no static-glob equivalent for CSS. """ source_lines = [ - f'@source "{e.pages_dir.as_posix()}/**/*.{{ts,tsx}}";' + f'@source "{d.as_posix()}/**/*.{{ts,tsx}}";' for e in assets - if e.pages_dir and not in_repo(e.pages_dir) + for d in (e.pages_dir, e.components_dir) + if d and not in_repo(d) ] theme_lines = [ f'@import "{ALIAS_PREFIX}/{e.package_name}/{THEME_CSS}";' for e in assets if e.theme_css @@ -152,6 +165,7 @@ def render_assets_json(assets: Sequence[ModuleAssets]) -> str: "package_name": e.package_name, "package": e.package_dir.as_posix(), "pages": e.pages_dir.as_posix() if e.pages_dir else None, + "components": e.components_dir.as_posix() if e.components_dir else None, "theme": e.theme_css.as_posix() if e.theme_css else None, "styles": e.styles_css.as_posix() if e.styles_css else None, }