simple-module-cli 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 (99) hide show
  1. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/.gitignore +1 -0
  2. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/PKG-INFO +1 -1
  3. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/pyproject.toml +1 -1
  4. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/catalog.py +6 -0
  5. simple_module_cli-0.0.27/simple_module_cli/templates/host/client_app/styles.css +27 -0
  6. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/vite.config.ts +34 -0
  7. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/README.md.tpl +18 -0
  8. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -1
  9. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_wizard.py +7 -1
  10. simple_module_cli-0.0.27/tests/test_module_css_assets.py +223 -0
  11. simple_module_cli-0.0.27/tests/test_module_css_build.py +72 -0
  12. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_module_pages_manifest.py +9 -2
  13. simple_module_cli-0.0.26/simple_module_cli/templates/host/client_app/styles.css +0 -12
  14. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/LICENSE +0 -0
  15. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/README.md +0 -0
  16. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/__init__.py +0 -0
  17. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/_env.py +0 -0
  18. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/app_project.py +0 -0
  19. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/case.py +0 -0
  20. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/cli.py +0 -0
  21. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/new.py +0 -0
  22. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/package_update.py +0 -0
  23. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/pins.py +0 -0
  24. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/plugins.py +0 -0
  25. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/recipes.py +0 -0
  26. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/scaffolding.py +0 -0
  27. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/README.md +0 -0
  28. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  29. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  30. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  31. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  32. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  33. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  34. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  35. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  36. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  37. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  38. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/skills_cmd.py +0 -0
  39. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/.env.example +0 -0
  40. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/.gitignore +0 -0
  41. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/Makefile +0 -0
  42. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/README.md.tpl +0 -0
  43. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  44. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
  45. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -0
  46. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  47. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
  48. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/alembic.ini +0 -0
  49. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  50. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  51. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  52. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  53. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  54. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  55. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  56. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/main.py +0 -0
  57. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/migrations/env.py +0 -0
  58. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  59. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  60. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  61. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/routes.py +0 -0
  62. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/host/templates/index.html +0 -0
  63. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  64. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  65. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/.gitignore +0 -0
  66. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  67. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  68. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  69. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  70. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
  71. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  72. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  73. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/package.json.tpl +0 -0
  74. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  75. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  76. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/workspace/.env.example +0 -0
  77. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/workspace/.gitignore +0 -0
  78. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/workspace/Makefile +0 -0
  79. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/workspace/README.md.tpl +0 -0
  80. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  81. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
  82. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/simple_module_cli/wizard.py +0 -0
  83. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_build_packaging.py +0 -0
  84. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_case.py +0 -0
  85. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_catalog.py +0 -0
  86. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_new.py +0 -0
  87. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_new_dest_tolerance.py +0 -0
  88. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_new_regressions.py +0 -0
  89. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_new_scaffold_layout.py +0 -0
  90. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_package_update.py +0 -0
  91. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_cli_recipes.py +0 -0
  92. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_create_module_ci_skip.py +0 -0
  93. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_env_helper.py +0 -0
  94. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_no_framework_deps.py +0 -0
  95. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_plugin_discovery.py +0 -0
  96. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_scaffold_rollback.py +0 -0
  97. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_scaffolding_host.py +0 -0
  98. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_scaffolding_module.py +0 -0
  99. {simple_module_cli-0.0.26 → simple_module_cli-0.0.27}/tests/test_skills_cmd.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_cli
3
- Version: 0.0.26
3
+ Version: 0.0.27
4
4
  Summary: Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host.
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_cli"
3
- version = "0.0.26"
3
+ version = "0.0.27"
4
4
  description = "Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -57,6 +57,12 @@ CATALOG: dict[str, ModuleEntry] = {
57
57
  requires=("users",),
58
58
  recipe="background_tasks",
59
59
  ),
60
+ "site_lock": ModuleEntry(
61
+ "site_lock",
62
+ "simple_module_site_lock",
63
+ "Site Lock",
64
+ requires=("auth", "settings"),
65
+ ),
60
66
  }
61
67
 
62
68
 
@@ -0,0 +1,27 @@
1
+ @import "@simple-module-py/ui/styles/globals.css";
2
+ @import "./modules.generated.css";
3
+
4
+ /* App-level source scanning — host code + workspace modules.
5
+ * Wheel-installed modules are covered by modules.generated.css.
6
+ *
7
+ * The module glob deliberately covers the whole package, not just pages/:
8
+ * shared components, hooks and Puck blocks carry Tailwind classes too, and
9
+ * scanning pages/ alone meant such a class only reached the bundle when some
10
+ * other scanned file happened to use it as well. The {ts,tsx} filter is what
11
+ * keeps .py files out of the scan. */
12
+ @source "./**/*.{ts,tsx}";
13
+ @source "../../modules/*/*/**/*.{ts,tsx}";
14
+ /* The second `*` above matches any directory under modules/<name>/, not just
15
+ * the Python package — so a module's sibling tests/ would be scanned too and
16
+ * test-only classes would land in the production bundle.
17
+ *
18
+ * Consequence: a module's Python package must not itself be named `tests`
19
+ * (i.e. modules/tests/tests/), or this line would exclude the package too. No
20
+ * glob can tell a package directory from a sibling, and `tests` is not a
21
+ * viable package name anyway — it collides with pytest collection. */
22
+ @source not "../../modules/*/tests/**";
23
+
24
+ body {
25
+ margin: 0;
26
+ font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
27
+ }
@@ -68,6 +68,37 @@ if (fs.existsSync(manifestPath)) {
68
68
  }
69
69
  }
70
70
  }
71
+
72
+ // Per-module aliases, so generated CSS can say
73
+ // @import "#module/gis/styles.css"
74
+ // instead of a ../../../.venv/lib/python3.12/site-packages/gis/styles.css
75
+ // path that breaks the moment the interpreter version changes.
76
+ //
77
+ // `@tailwindcss/vite` builds its CSS import resolver with
78
+ // `createResolver({ ...config.resolve, ... })`, so `resolve.alias` governs
79
+ // CSS `@import` as well as JS — verified against @tailwindcss/vite 4.2.4.
80
+ //
81
+ // Read from modules.assets.json rather than modules.manifest.json: the
82
+ // manifest is keyed off `pages/`, so a module shipping only CSS never
83
+ // appears in it.
84
+ type ModuleAsset = { package_name: string; package: string };
85
+ const moduleAliases: { find: string; replacement: string }[] = [];
86
+ const assetsPath = path.resolve(__dirname, 'modules.assets.json');
87
+ let moduleAssets: Record<string, ModuleAsset> = {};
88
+ try {
89
+ moduleAssets = JSON.parse(fs.readFileSync(assetsPath, 'utf-8'));
90
+ } catch {
91
+ // Absent until `smpy gen-pages` runs — proceed with no aliases.
92
+ }
93
+ for (const entry of Object.values(moduleAssets)) {
94
+ moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package });
95
+ if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package);
96
+ }
97
+ // Keep the alias list in a stable, longest-first order. Vite matches a string
98
+ // `find` on exact equality or a `/`-bounded prefix, so `#module/gis` could not
99
+ // swallow `#module/gis_extra` in any order — this is just determinism, not a
100
+ // correctness fix.
101
+ moduleAliases.sort((a, b) => b.find.length - a.find.length);
71
102
  const fakeWorkspaceImporter = path.join(fsRoot, 'package.json');
72
103
 
73
104
  // CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority`
@@ -200,6 +231,9 @@ export default defineConfig({
200
231
  plugins: [moduleBareImportResolver(), react(), tailwindcss()],
201
232
  root: __dirname,
202
233
  resolve: {
234
+ // `#module/<pkg>` -> that module's package directory. Consumed by the
235
+ // @import lines in modules.generated.css.
236
+ alias: moduleAliases,
203
237
  dedupe: [...REACT_CORE_DEPS, '@simple-module-py/ui', '@simple-module-py/i18n'],
204
238
  },
205
239
  optimizeDeps: {
@@ -39,6 +39,24 @@ Hosts mount the bundle automatically at
39
39
  when it exists, an empty dict otherwise (so dev without a build step
40
40
  doesn't fail).
41
41
 
42
+ ## Styling (optional)
43
+
44
+ This module can ship CSS that lands in the host's Tailwind build with no
45
+ wiring on the host side. Create either file beside `pages/` and it is
46
+ picked up automatically — the scaffold deliberately does not create them
47
+ empty, since a file that is always imported but usually empty is noise:
48
+
49
+ - `{{PACKAGE_NAME}}/theme.css` — `@theme` tokens, `@custom-variant`,
50
+ `@font-face`. Imported **unlayered**, because a `@theme` block inside a
51
+ cascade layer registers nothing.
52
+ - `{{PACKAGE_NAME}}/styles.css` — component rules, keyframes, vendor CSS.
53
+ Imported into **`layer(components)`**, so these rules always lose to a
54
+ Tailwind utility and a consuming app can still override them.
55
+
56
+ Putting a construct in the wrong file builds fine but cascades in a way you
57
+ probably did not intend, so `make doctor` warns: `SM022` for `@theme` in
58
+ `styles.css`, `SM023` for an unlayered rule in `theme.css`.
59
+
42
60
  ## Continuous integration
43
61
 
44
62
  Two workflows live under `.github/workflows/`:
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "extends": "@simple-module-py/tsconfig/base.json",
3
3
  "compilerOptions": {
4
- "baseUrl": ".",
5
4
  "paths": {
6
5
  "@/*": ["./{{PACKAGE_NAME}}/*"],
7
6
  "@simple-module-py/ui/*": ["../../packages/ui/src/*"]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import typer
6
+ from simple_module_cli.catalog import CATALOG
6
7
  from simple_module_cli.wizard import run_wizard
7
8
  from typer.testing import CliRunner
8
9
 
@@ -52,7 +53,12 @@ def test_wizard_full_preset_includes_background_tasks() -> None:
52
53
 
53
54
 
54
55
  def test_wizard_custom_picks_only_yes_answers() -> None:
55
- answers = ["", "", "4"] + ["n"] * 7 + ["y", ""]
56
+ # The custom path prompts once per catalog module, in CATALOG order.
57
+ # Derive the answers from CATALOG rather than hardcoding a count: a
58
+ # hardcoded sequence silently shifts onto the wrong module the next time
59
+ # one is added, which is how this test broke when `site_lock` landed.
60
+ picks = ["y" if name == "background_tasks" else "n" for name in CATALOG]
61
+ answers = ["", "", "4", *picks, ""]
56
62
  _, _, selected, out = _drive(answers)
57
63
  assert set(selected) == {"background_tasks", "users", "auth"}
58
64
  assert "Added users (required by background_tasks)" in out
@@ -0,0 +1,223 @@
1
+ """Tests for module CSS asset discovery and emission (`smpy gen-pages`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _make_importable_module(tmp_path: Path, pkg_name: str, klass_name: str):
10
+ """Create a real importable package on disk and return a ModuleBase bound to it.
11
+
12
+ ``get_module_package_name`` derives the package from the class's
13
+ ``__module__``, and ``compute_module_assets`` then resolves that package
14
+ via ``importlib.resources.files``. So the package has to genuinely exist
15
+ on ``sys.path`` — a mock won't exercise the code path we care about.
16
+ """
17
+ from simple_module_core import ModuleBase, ModuleMeta
18
+
19
+ pkg = tmp_path / pkg_name
20
+ pkg.mkdir(parents=True, exist_ok=True)
21
+ (pkg / "__init__.py").write_text("", encoding="utf-8")
22
+ if str(tmp_path) not in sys.path:
23
+ sys.path.insert(0, str(tmp_path))
24
+ sys.modules.pop(pkg_name, None)
25
+
26
+ klass = type(
27
+ klass_name,
28
+ (ModuleBase,),
29
+ {"meta": ModuleMeta(name=klass_name), "__module__": f"{pkg_name}.module"},
30
+ )
31
+ return klass(), pkg
32
+
33
+
34
+ class TestComputeModuleAssets:
35
+ async def test_detects_theme_and_styles(self, tmp_path):
36
+ """A module shipping theme.css/styles.css has both detected."""
37
+ from simple_module_hosting.assets import compute_module_assets
38
+
39
+ mod, pkg = _make_importable_module(tmp_path, "styled_mod", "Styled")
40
+ (pkg / "pages").mkdir()
41
+ (pkg / "theme.css").write_text("@theme { --color-x: red; }\n", encoding="utf-8")
42
+ (pkg / "styles.css").write_text(".x { color: red; }\n", encoding="utf-8")
43
+
44
+ result = compute_module_assets([mod])
45
+
46
+ assert len(result) == 1
47
+ entry = result[0]
48
+ assert entry.package_name == "styled_mod"
49
+ assert entry.theme_css is not None and entry.theme_css.name == "theme.css"
50
+ assert entry.styles_css is not None and entry.styles_css.name == "styles.css"
51
+ assert entry.pages_dir is not None
52
+
53
+ async def test_css_only_module_is_included(self, tmp_path):
54
+ """A module with CSS but no pages/ still appears — the manifest.json gap."""
55
+ from simple_module_hosting.assets import compute_module_assets
56
+
57
+ mod, pkg = _make_importable_module(tmp_path, "cssonly_mod", "CssOnly")
58
+ (pkg / "styles.css").write_text(".y { color: blue; }\n", encoding="utf-8")
59
+
60
+ result = compute_module_assets([mod])
61
+
62
+ assert [e.name for e in result] == ["CssOnly"]
63
+ assert result[0].pages_dir is None
64
+ assert result[0].theme_css is None
65
+ assert result[0].styles_css is not None
66
+
67
+ async def test_module_with_no_assets_is_omitted(self, tmp_path):
68
+ """A module contributing nothing frontend-ish is skipped, not an error."""
69
+ from simple_module_hosting.assets import compute_module_assets
70
+
71
+ mod, _pkg = _make_importable_module(tmp_path, "headless_mod", "Headless")
72
+
73
+ assert compute_module_assets([mod]) == []
74
+
75
+ async def test_preserves_discovery_order(self):
76
+ """Order follows discover_modules() (topological), not alphabetical."""
77
+ from simple_module_core import discover_modules
78
+ from simple_module_hosting.assets import compute_module_assets
79
+
80
+ modules = discover_modules()
81
+ result = compute_module_assets(modules)
82
+
83
+ names = [e.name for e in result]
84
+ discovery_order = [m.meta.name for m in modules]
85
+ assert names == [n for n in discovery_order if n in set(names)]
86
+
87
+
88
+ def _assets(tmp_path: Path, **overrides):
89
+ """Build a single ModuleAssets entry with sensible defaults."""
90
+ from simple_module_hosting.assets import ModuleAssets
91
+
92
+ defaults = {
93
+ "name": "Gis",
94
+ "package_name": "gis",
95
+ "package_dir": tmp_path / "gis",
96
+ "pages_dir": None,
97
+ "theme_css": None,
98
+ "styles_css": None,
99
+ }
100
+ return ModuleAssets(**{**defaults, **overrides})
101
+
102
+
103
+ class TestCssEmission:
104
+ async def test_styles_layered_theme_unlayered(self, tmp_path):
105
+ """theme.css imports unlayered; styles.css imports into layer(components)."""
106
+ from simple_module_hosting.assets import render_modules_css
107
+
108
+ entry = _assets(
109
+ tmp_path,
110
+ theme_css=tmp_path / "gis" / "theme.css",
111
+ styles_css=tmp_path / "gis" / "styles.css",
112
+ )
113
+ css = render_modules_css([entry], in_repo=lambda _p: False)
114
+
115
+ assert '@import "#module/gis/theme.css";' in css
116
+ assert '@import "#module/gis/styles.css" layer(components);' in css
117
+ # Tokens must be declared before the rules that consume them.
118
+ assert css.index("theme.css") < css.index("styles.css")
119
+
120
+ async def test_source_skips_in_repo_but_import_does_not(self, tmp_path):
121
+ """@source is wheel-only; @import is emitted for every module."""
122
+ from simple_module_hosting.assets import render_modules_css
123
+
124
+ entry = _assets(
125
+ tmp_path,
126
+ name="Local",
127
+ package_name="local",
128
+ pages_dir=tmp_path / "local" / "pages",
129
+ styles_css=tmp_path / "local" / "styles.css",
130
+ )
131
+ css = render_modules_css([entry], in_repo=lambda _p: True)
132
+
133
+ assert "@source" not in css, "in-repo pages are covered by the static glob"
134
+ assert '@import "#module/local/styles.css" layer(components);' in css
135
+
136
+ async def test_source_emitted_for_wheel_modules(self, tmp_path):
137
+ """A wheel-installed module gets an absolute @source glob."""
138
+ from simple_module_hosting.assets import render_modules_css
139
+
140
+ pages = tmp_path / "gis" / "pages"
141
+ css = render_modules_css([_assets(tmp_path, pages_dir=pages)], in_repo=lambda _p: False)
142
+
143
+ assert f'@source "{pages.as_posix()}/**/*.{{ts,tsx}}";' in css
144
+
145
+ async def test_module_without_css_emits_no_import(self, tmp_path):
146
+ """Pages-only modules contribute @source but no @import."""
147
+ from simple_module_hosting.assets import render_modules_css
148
+
149
+ entry = _assets(tmp_path, pages_dir=tmp_path / "gis" / "pages")
150
+ css = render_modules_css([entry], in_repo=lambda _p: False)
151
+
152
+ assert "@import" not in css
153
+
154
+ async def test_output_is_formatter_clean(self, tmp_path):
155
+ """No doubled blank lines, exactly one trailing newline.
156
+
157
+ `biome ci .` lints the whole tree, and `modules.generated.css` is
158
+ untracked but not exempt — so a stray blank line here fails `make lint`
159
+ for anyone who has run `gen-pages`.
160
+ """
161
+ from simple_module_hosting.assets import render_modules_css
162
+
163
+ for entry in (
164
+ _assets(tmp_path, styles_css=tmp_path / "gis" / "styles.css"),
165
+ _assets(tmp_path, pages_dir=tmp_path / "gis" / "pages"),
166
+ ):
167
+ css = render_modules_css([entry], in_repo=lambda _p: False)
168
+ assert "\n\n\n" not in css, f"doubled blank line in:\n{css!r}"
169
+ assert css.endswith("\n") and not css.endswith("\n\n"), repr(css[-20:])
170
+
171
+ empty = render_modules_css([], in_repo=lambda _p: False)
172
+ assert "\n\n\n" not in empty and empty.endswith("\n")
173
+
174
+ async def test_no_relative_paths_anywhere(self, tmp_path):
175
+ """The whole point of aliases: generated @import lines never use ../.."""
176
+ from simple_module_core import discover_modules
177
+ from simple_module_hosting.manifest import write_module_pages_manifest
178
+
179
+ written = write_module_pages_manifest(discover_modules(), tmp_path)
180
+ css = written["css"].read_text(encoding="utf-8")
181
+
182
+ for line in css.splitlines():
183
+ if line.startswith("@import"):
184
+ assert "../" not in line, f"@import must not use a relative path: {line}"
185
+ assert '"#module/' in line, f"@import must use the alias prefix: {line}"
186
+
187
+
188
+ class TestAssetsManifest:
189
+ async def test_writes_assets_json(self, tmp_path):
190
+ """modules.assets.json is emitted alongside the existing three files."""
191
+ import json
192
+
193
+ from simple_module_core import discover_modules
194
+ from simple_module_hosting.manifest import write_module_pages_manifest
195
+
196
+ written = write_module_pages_manifest(discover_modules(), tmp_path)
197
+
198
+ assets_path = tmp_path / "modules.assets.json"
199
+ assert assets_path.is_file()
200
+ assert written["assets"] == assets_path
201
+
202
+ data = json.loads(assets_path.read_text(encoding="utf-8"))
203
+ entry = data["Dashboard"]
204
+ assert entry["package_name"] == "dashboard"
205
+ assert entry["package"].endswith("dashboard")
206
+ assert set(entry) == {"package_name", "package", "pages", "theme", "styles"}
207
+
208
+ async def test_manifest_json_shape_unchanged(self, tmp_path):
209
+ """modules.manifest.json stays {name: pages_dir} for downstream vite configs.
210
+
211
+ Every downstream app holds its own copy of vite.config.ts and reads
212
+ this file; changing its value shape would break them all at once.
213
+ """
214
+ import json
215
+
216
+ from simple_module_core import discover_modules
217
+ from simple_module_hosting.manifest import write_module_pages_manifest
218
+
219
+ write_module_pages_manifest(discover_modules(), tmp_path)
220
+
221
+ data = json.loads((tmp_path / "modules.manifest.json").read_text(encoding="utf-8"))
222
+ assert data, "manifest should not be empty"
223
+ assert all(isinstance(v, str) for v in data.values())
@@ -0,0 +1,72 @@
1
+ """Proves module-shipped CSS survives a real Tailwind build.
2
+
3
+ Every unit test around `render_modules_css` can stay green while Tailwind
4
+ emits nothing at all — the generated `@import` only pays off if Vite resolves
5
+ the `#module/<pkg>` alias and Tailwind keeps the rule. This is the one
6
+ assertion that exercises that whole chain, so it drives a genuine build
7
+ rather than inspecting the generated text.
8
+
9
+ Skipped where a build is impossible: CI's `python-tests` job installs Python
10
+ deps only (`make install-py`), so `node_modules` is absent there. The JS jobs
11
+ and local runs do have it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ REPO_ROOT = Path(__file__).resolve().parents[3]
23
+
24
+ pytestmark = [
25
+ pytest.mark.skipif(
26
+ not (REPO_ROOT / "node_modules").is_dir(),
27
+ reason="node_modules absent — run `npm install` to exercise the real build",
28
+ ),
29
+ pytest.mark.skipif(shutil.which("npm") is None, reason="npm not on PATH"),
30
+ ]
31
+
32
+
33
+ class TestModuleCssReachesBundle:
34
+ def test_module_class_is_emitted_by_vite_build(self):
35
+ """A class defined only in a module's styles.css appears in the built CSS.
36
+
37
+ `.dashboard-stat-grid` is declared in modules/dashboard/dashboard/styles.css
38
+ and referenced by no TSX anywhere, so it can only reach the bundle via the
39
+ generated `@import "#module/dashboard/styles.css"`.
40
+ """
41
+ subprocess.run(
42
+ [
43
+ "uv",
44
+ "run",
45
+ "--project",
46
+ "host",
47
+ "smpy",
48
+ "host",
49
+ "gen-pages",
50
+ "--host-dir=host/client_app",
51
+ ],
52
+ cwd=REPO_ROOT,
53
+ check=True,
54
+ capture_output=True,
55
+ )
56
+
57
+ generated = (REPO_ROOT / "host/client_app/modules.generated.css").read_text(
58
+ encoding="utf-8"
59
+ )
60
+ assert '@import "#module/dashboard/styles.css" layer(components);' in generated, (
61
+ f"gen-pages did not emit the dashboard stylesheet import:\n{generated}"
62
+ )
63
+
64
+ subprocess.run(["npm", "run", "build"], cwd=REPO_ROOT, check=True, capture_output=True)
65
+
66
+ built = list((REPO_ROOT / "host/static/dist/assets").glob("*.css"))
67
+ assert built, "no CSS emitted by the build"
68
+ combined = "\n".join(p.read_text(encoding="utf-8") for p in built)
69
+ assert "dashboard-stat-grid" in combined, (
70
+ "module-shipped CSS did not reach the bundle — the #module alias "
71
+ "most likely failed to resolve"
72
+ )
@@ -33,7 +33,7 @@ class TestModulePagesManifest:
33
33
  assert "Headless" not in result
34
34
 
35
35
  async def test_write_manifest_emits_json_and_ts(self, tmp_path):
36
- """write_module_pages_manifest emits the JSON manifest, TS glob, and Tailwind CSS files."""
36
+ """write_module_pages_manifest emits the JSON manifest, TS glob, CSS and assets files."""
37
37
  import json
38
38
 
39
39
  from simple_module_core import discover_modules
@@ -45,10 +45,17 @@ class TestModulePagesManifest:
45
45
  manifest = tmp_path / "modules.manifest.json"
46
46
  generated = tmp_path / "modules.generated.ts"
47
47
  css = tmp_path / "modules.generated.css"
48
+ assets = tmp_path / "modules.assets.json"
48
49
  assert manifest.is_file()
49
50
  assert generated.is_file()
50
51
  assert css.is_file()
51
- assert written == {"manifest": manifest, "generated": generated, "css": css}
52
+ assert assets.is_file()
53
+ assert written == {
54
+ "manifest": manifest,
55
+ "generated": generated,
56
+ "css": css,
57
+ "assets": assets,
58
+ }
52
59
 
53
60
  data = json.loads(manifest.read_text(encoding="utf-8"))
54
61
  assert "Dashboard" in data
@@ -1,12 +0,0 @@
1
- @import "@simple-module-py/ui/styles/globals.css";
2
- @import "./modules.generated.css";
3
-
4
- /* App-level source scanning — host code + workspace module pages.
5
- * Wheel-installed modules are covered by modules.generated.css. */
6
- @source "./**/*.{ts,tsx}";
7
- @source "../../modules/*/*/pages/**/*.{ts,tsx}";
8
-
9
- body {
10
- margin: 0;
11
- font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
12
- }