Skip to content

Commit 0002e5d

Browse files
authored
feat(cli): out-of-tree module frontend DX — standalone scaffold fixes, smpy module verify/build (#242)
* docs: design spec for out-of-tree module JS/CSS DX Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * docs: implementation plan for out-of-tree frontend DX Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * fix(cli): module tsconfig resolves ui from node_modules, not monorepo path Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * feat(cli): standalone module scaffold gets working npm/tsconfig setup Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * feat(cli): module scaffold ships a working sample page + view route Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * feat(cli): standalone module CI typechecks and verifies the frontend Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * feat(cli): smpy module group + cached verify-host bootstrap Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * feat(cli): smpy module verify — build the frontend against a scaffolded host Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * feat(cli): smpy module build — bundle static_mounts assets via lib-mode vite Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * fix(hosting): declare itsdangerous — SessionMiddleware needs it, out-of-tree hosts had nothing pulling it in Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * test(cli): e2e — scaffolded standalone module verifies green Fixes the e2e surfaced: static/dist ships via hatch artifacts (force-include failed the editable build on fresh scaffolds, breaking uv sync), and verify now npm-installs the module repo first so esbuild can resolve the module tsconfig's extends. Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * docs: out-of-tree frontend development — typecheck, verify, build Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * style: ruff-format python blocks in plan doc Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1 * refactor(cli): extract template engine to _templating.py (300-line cap) Claude-Session: https://claude.ai/code/session_01QnK4aCdypCuBnpvP4RuvV1
1 parent a5d6c12 commit 0002e5d

28 files changed

Lines changed: 2478 additions & 120 deletions

‎docs/module-authoring.md‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,13 @@ Vite's `server.fs.allow` is extended to cover each installed module's
252252
package root, so pages shipped inside a wheel work for the dev server and
253253
production build alike.
254254

255-
For production builds, ship a pre-bundled `my_module/static/dist/` inside
256-
the wheel and expose it via `ModuleBase.static_mounts()`:
255+
**Inertia pages never need pre-bundling.** The consuming host's Vite build
256+
compiles `pages/*.tsx` straight out of the installed wheel (via the
257+
`#module/<pkg>` aliases + `server.fs.allow`). `static/dist/` +
258+
`static_mounts()` exist only for assets *outside* that pipeline — vendor JS,
259+
standalone widgets, images. Build them with `smpy module build` (see
260+
[Developing out-of-tree](#developing-out-of-tree)) and expose them via
261+
`ModuleBase.static_mounts()`:
257262

258263
```python
259264
from importlib.resources import files
@@ -339,9 +344,62 @@ final word from its own `@theme` block below the generated import.
339344
**No packaging change is required.** The module wheel template already
340345
declares `[tool.hatch.build.targets.wheel] packages = ["my_module"]`, and
341346
Hatch includes every file under the package directory — `.css` along with
342-
`.tsx`. The `force-include` block is only needed for artifacts that live
343-
*outside* the package dir (`package.json`) or that are gitignored
344-
(`static/dist`).
347+
`.tsx`. Only two extras need declaring: `package.json` lives *outside* the
348+
package dir (a `force-include` maps it in), and `static/dist` is gitignored
349+
(an `artifacts` entry ships it when present without failing the build when
350+
it isn't).
351+
352+
## Developing out-of-tree
353+
354+
A module in its own repo has no host around it — these are the three
355+
commands that close the gap. All of them run from the module repo root.
356+
357+
### Type-checking
358+
359+
`smpy create-module` scaffolds a `package.json` whose devDependencies pin
360+
`@simple-module-py/ui`, `@simple-module-py/tsconfig` and
361+
`@simple-module-py/i18n` to the framework version that created the module
362+
(all three are published to npm in lockstep with the PyPI packages), and a
363+
`tsconfig.json` that resolves `@simple-module-py/ui/*` from your own
364+
`node_modules`. So:
365+
366+
```bash
367+
npm install # once; commit package-lock.json and use `npm ci` in CI
368+
npm run typecheck # tsc --noEmit over your pages
369+
```
370+
371+
### `smpy module verify` — does my frontend actually build?
372+
373+
`tsc` alone cannot tell you whether your pages and `theme.css`/`styles.css`
374+
survive a real host build (Vite import resolution, Tailwind scanning, the
375+
`#module/<pkg>` alias plumbing). `verify` answers that by scaffolding a
376+
throwaway host into `.smpy/verify-host/` (cached, gitignored), installing
377+
your module into it as an editable path dependency, and running the host's
378+
real `gen-pages` + `npm run build`:
379+
380+
```bash
381+
uv run smpy module verify # warm re-runs reuse the cached host
382+
uv run smpy module verify --fresh # nuke and rebuild the cached host
383+
```
384+
385+
The scaffolded CI workflow runs it on every push. The verify host pins the
386+
framework version you develop against, so a green verify ≈ your module
387+
building inside a freshly scaffolded host of that version. It needs `uv`,
388+
`npm`, and network access to PyPI + npm on first run.
389+
390+
### `smpy module build` — static_mounts() assets
391+
392+
If (and only if) your module ships assets outside the Inertia page pipeline,
393+
put an entry file at `<pkg>/assets_src/index.ts` and run:
394+
395+
```bash
396+
uv run smpy module build
397+
```
398+
399+
It bundles `assets_src/` into `<pkg>/static/dist/` (IIFE, via the verify
400+
host's Vite toolchain — your repo needs no bundler devDependency). The
401+
scaffolded `pyproject.toml` already ships `static/dist` in the wheel via a
402+
hatch `artifacts` entry; the command warns if that entry has been removed.
345403

346404
## Templates
347405

‎docs/superpowers/plans/2026-08-06-out-of-tree-frontend-dx.md‎

Lines changed: 1340 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Out-of-tree module JS/CSS DX — Design
2+
3+
**Goal:** Make the frontend half of a standalone module repo (a module living
4+
outside this monorepo) work out of the box: correct editor/typecheck setup,
5+
a CI-runnable proof that the module's TSX + CSS compile against the real host
6+
toolchain, and a build path for `static_mounts()` assets.
7+
8+
**Date:** 2026-08-06
9+
**Approach chosen:** Extend `smpy` with a `module` command group + fix the
10+
`create-module` scaffold templates (Approach A). Rejected: template-only
11+
`dev-host/` committed into each module repo (drifts from the framework);
12+
separate `simple_module_devkit` package (release overhead with no benefit over
13+
the existing CLI).
14+
15+
---
16+
17+
## Problem
18+
19+
The `create-module` scaffold and docs assume the monorepo:
20+
21+
1. `tsconfig.json.tpl` maps `@simple-module-py/ui/*` to `../../packages/ui/src/*`
22+
— a path that only exists inside this repo. Standalone repos get broken
23+
type-checking and editor IntelliSense out of the box.
24+
2. There is no way to compile the module's frontend standalone. Pages import
25+
`@simple-module-py/ui/components/...` subpaths and ship `theme.css` /
26+
`styles.css` that only mean anything inside a host's Vite + Tailwind build —
27+
so the first signal that something is wrong arrives after publishing, inside
28+
a consuming host.
29+
3. `docs/module-authoring.md` says "for production builds, ship a pre-bundled
30+
`static/dist`" — misleading: wheel modules' TSX pages are compiled by the
31+
*host's* Vite build (via `#module/<pkg>` aliases + `fs.allow`).
32+
`static/dist` + `static_mounts()` is only for assets *outside* that pipeline
33+
(vendor JS, widgets, images), and there is no tooling to produce one.
34+
4. The module scaffold has no JS typecheck/lint scripts and its CI runs Python
35+
only.
36+
37+
Both `@simple-module-py/ui` and `@simple-module-py/tsconfig` are already
38+
published to npm, versioned in lockstep with the framework — the raw materials
39+
exist; the scaffold and CLI just don't use them.
40+
41+
## Deliverables
42+
43+
### 1. Scaffold fixes (editor/typecheck correctness)
44+
45+
`create-module` already distinguishes standalone vs in-repo (`include_ci`
46+
logic, GH #210). The JS templates become variant-aware on the same switch:
47+
48+
- **`tsconfig.json` (standalone variant):** paths resolve via the installed
49+
npm package:
50+
`"@simple-module-py/ui/*": ["./node_modules/@simple-module-py/ui/src/*"]`
51+
(and the same for `@simple-module-py/i18n`). The in-repo variant keeps the
52+
current `../../packages/*` mapping.
53+
- **`package.json` (standalone variant):** peerDependencies stay as the
54+
host-contract; add pinned devDependencies so `npm install` + `tsc` work in
55+
the repo: `@simple-module-py/ui`, `@simple-module-py/tsconfig`,
56+
`typescript`, `@types/react`, `@types/react-dom` — pinned with the existing
57+
`{{FRAMEWORK_VERSION}}` mechanism where applicable. Add
58+
`"scripts": {"typecheck": "tsc --noEmit"}`.
59+
- **`ci.yml` (standalone):** add a JS job — setup-node, `npm ci`,
60+
`npm run typecheck`, then `uv run smpy module verify`.
61+
`simple_module_cli` joins the scaffold's `dev` extra.
62+
- **`.gitignore`:** add `.smpy/`.
63+
64+
Rejected alternative: adding an `exports` map to the published
65+
`@simple-module-py/ui` package would remove the need for `paths` everywhere,
66+
but it touches the host's Vite alias machinery and every existing consumer —
67+
out of scope; possible later refactor.
68+
69+
### 2. `smpy module verify`
70+
71+
New `smpy module` Typer group in `framework/cli`. `verify` proves the module's
72+
TSX + `theme.css`/`styles.css` compile against the real host toolchain:
73+
74+
1. Scaffold (or reuse) an ephemeral host at `.smpy/verify-host/` using the
75+
existing `create-host` templates, with the module wired in as an editable
76+
path dependency via `[tool.uv.sources]`.
77+
2. Run, in the host: `uv sync` → `npm install` (in `client_app/`) →
78+
`smpy host gen-pages` → `npm run build` (`tsc && vite build`).
79+
3. Propagate the exit code — CI-friendly. Print the failing step's output.
80+
81+
Details:
82+
83+
- **Caching:** `.smpy/verify-host/` persists between runs (gitignored), so
84+
warm re-runs skip scaffolding and get fast idempotent `npm install`.
85+
`--fresh` deletes and rebuilds it.
86+
- **Version:** the ephemeral host is pinned to the CLI's installed framework
87+
version via the existing `resolve_framework_version()` — verify passing ≈
88+
the module working in a freshly scaffolded host of that version.
89+
- **No DB / no boot:** `gen-pages` only does discovery + file emission, so
90+
verify needs no database, no `.env`, no running server, and no auth module.
91+
- **Preconditions:** run from the module repo root; error clearly if no
92+
`[project.entry-points.simple_module]` is found in `pyproject.toml`.
93+
94+
### 3. `smpy module build`
95+
96+
For `static_mounts()` assets only (vendor JS, widgets — outside the Inertia
97+
page pipeline):
98+
99+
- **Convention:** sources in `<pkg>/assets_src/`, output to
100+
`<pkg>/static/dist/`.
101+
- Reuses the verify-host's node toolchain (same bootstrap path as `verify`),
102+
generating a Vite **lib-mode** config on the fly — no Vite devDependency in
103+
the module repo.
104+
- Warns if the module's `pyproject.toml` lacks the hatch `force-include` for
105+
`static/dist` (it is gitignored, so wheels would silently omit it).
106+
- Errors clearly if `assets_src/` does not exist (the command is opt-in; most
107+
modules never need it).
108+
109+
## Testing
110+
111+
- Unit tests for the orchestration in `framework/cli/tests`, with subprocess
112+
boundaries (uv/npm invocations) mocked: scaffold reuse vs `--fresh`,
113+
editable-source wiring, precondition errors, exit-code propagation,
114+
force-include warning.
115+
- One real end-to-end test (scaffold a module → `verify` green) behind the
116+
existing `e2e` marker, since it needs npm and network access.
117+
- Template-variant tests: standalone vs in-repo emit the right
118+
`tsconfig.json` / `package.json` / `ci.yml`.
119+
120+
## Docs
121+
122+
- New "Developing out-of-tree" section in `docs/module-authoring.md`: the
123+
tsconfig/npm story, `verify` in CI, `build` for static assets.
124+
- Fix the misleading "ship a pre-bundled `static/dist`" wording in the
125+
Frontend assets section: pages are built by the consuming host;
126+
`static/dist` is only for extra-pipeline assets.
127+
128+
## Out of scope (this round)
129+
130+
- A live preview/dev-server loop for out-of-tree modules (`smpy module dev`) —
131+
the largest remaining gap, deliberately deferred.
132+
- `exports` map on the published npm packages.
133+
- Any change to how in-repo (`modules/*`) development works.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""``smpy module build`` — bundle ``<pkg>/assets_src/`` into ``<pkg>/static/dist/``.
2+
3+
Only for assets served via :meth:`ModuleBase.static_mounts` (vendor JS,
4+
widgets, images). Inertia pages do NOT need this — the consuming host's Vite
5+
build compiles ``pages/*.tsx`` straight from the wheel.
6+
7+
The module repo carries no bundler of its own: we borrow the verify host's
8+
node toolchain (``.smpy/verify-host/client_app``) and point Vite at a
9+
generated lib-mode config.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import subprocess
15+
from pathlib import Path
16+
17+
import typer
18+
19+
from simple_module_cli._module_host import ModuleInfo, ensure_verify_host, require_binary
20+
from simple_module_cli.case import to_pascal_case
21+
22+
_ENTRY_CANDIDATES = ("index.ts", "index.tsx", "index.js")
23+
24+
25+
def run_build(info: ModuleInfo, *, fresh: bool = False, runner=subprocess.run) -> None:
26+
assets_src = info.root / info.package_name / "assets_src"
27+
entry = next((assets_src / n for n in _ENTRY_CANDIDATES if (assets_src / n).is_file()), None)
28+
if entry is None:
29+
typer.echo(
30+
f"error: no {info.package_name}/assets_src/index.(ts|tsx|js) found. "
31+
"`smpy module build` bundles static_mounts() assets only — Inertia pages "
32+
"are built by the consuming host and need no bundling.",
33+
err=True,
34+
)
35+
raise typer.Exit(code=1)
36+
37+
host = ensure_verify_host(info, fresh=fresh)
38+
client_app = host / "client_app"
39+
npm = require_binary("npm")
40+
if not (client_app / "node_modules").is_dir():
41+
typer.echo("[build] npm install (first run)")
42+
if runner([npm, "install"], cwd=client_app).returncode != 0:
43+
typer.echo("[build] FAILED at: npm install", err=True)
44+
raise typer.Exit(code=1)
45+
46+
out_dir = info.root / info.package_name / "static" / "dist"
47+
config_path = info.root / ".smpy" / "module-build.config.mjs"
48+
config_path.parent.mkdir(parents=True, exist_ok=True)
49+
config_path.write_text(
50+
_vite_lib_config(entry, out_dir, global_name=to_pascal_case(info.package_name)),
51+
encoding="utf-8",
52+
)
53+
typer.echo(f"[build] vite build -> {info.package_name}/static/dist")
54+
npx = require_binary("npx")
55+
if runner([npx, "vite", "build", "--config", str(config_path)], cwd=client_app).returncode != 0:
56+
typer.echo("[build] FAILED at: vite build", err=True)
57+
raise typer.Exit(code=1)
58+
59+
_warn_missing_force_include(info)
60+
typer.echo("[build] OK")
61+
62+
63+
def _vite_lib_config(entry: Path, out_dir: Path, *, global_name: str) -> str:
64+
return f"""\
65+
import {{ defineConfig }} from 'vite'
66+
67+
export default defineConfig({{
68+
build: {{
69+
lib: {{
70+
entry: {entry.as_posix()!r},
71+
formats: ['iife'],
72+
name: {global_name!r},
73+
fileName: () => 'index.js',
74+
}},
75+
outDir: {out_dir.as_posix()!r},
76+
emptyOutDir: true,
77+
}},
78+
}})
79+
"""
80+
81+
82+
def _warn_missing_force_include(info: ModuleInfo) -> None:
83+
"""static/dist is gitignored — without an artifacts entry the wheel silently omits it."""
84+
pyproject = (info.root / "pyproject.toml").read_text(encoding="utf-8")
85+
if f"{info.package_name}/static/dist" not in pyproject:
86+
typer.echo(
87+
"warning: pyproject.toml does not ship "
88+
f'"{info.package_name}/static/dist" — the built bundle will NOT be in the wheel. '
89+
"Add under [tool.hatch.build.targets.wheel]:\n"
90+
f' artifacts = ["{info.package_name}/static/dist/"]',
91+
err=True,
92+
)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Shared plumbing for ``smpy module`` commands.
2+
3+
Out-of-tree module repos have no frontend toolchain of their own — the
4+
commands borrow one by scaffolding a throwaway host (via the same templates
5+
as ``smpy create-host``) into ``.smpy/verify-host/`` and caching it there.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import shutil
11+
import tomllib
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
15+
import typer
16+
17+
from simple_module_cli.scaffolding import create_host, resolve_framework_version
18+
19+
VERIFY_HOST_RELPATH = Path(".smpy") / "verify-host"
20+
21+
22+
@dataclass(frozen=True)
23+
class ModuleInfo:
24+
"""Identity of the module under the cwd, read from its pyproject."""
25+
26+
root: Path
27+
pypi_name: str # [project].name, e.g. simple_module_my_feature
28+
package_name: str # the simple_module entry-point key, e.g. my_feature
29+
30+
31+
def read_module_info(root: Path) -> ModuleInfo:
32+
pyproject = root / "pyproject.toml"
33+
if not pyproject.is_file():
34+
typer.echo(f"error: no pyproject.toml in {root} — run from the module repo root.", err=True)
35+
raise typer.Exit(code=1)
36+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
37+
entry_points = data.get("project", {}).get("entry-points", {}).get("simple_module", {})
38+
if not entry_points:
39+
typer.echo(
40+
"error: pyproject.toml declares no [project.entry-points.simple_module] — "
41+
"this does not look like a SimpleModule module.",
42+
err=True,
43+
)
44+
raise typer.Exit(code=1)
45+
return ModuleInfo(
46+
root=root,
47+
pypi_name=data["project"]["name"],
48+
package_name=next(iter(entry_points)),
49+
)
50+
51+
52+
def require_binary(name: str) -> str:
53+
"""Resolve ``name`` on PATH or exit with a clear message (npm on Windows is npm.cmd)."""
54+
path = shutil.which(name)
55+
if path is None:
56+
typer.echo(f"error: '{name}' not found on PATH — install it and retry.", err=True)
57+
raise typer.Exit(code=1)
58+
return path
59+
60+
61+
def ensure_verify_host(info: ModuleInfo, *, fresh: bool = False) -> Path:
62+
"""Scaffold (or reuse) the cached verify host; return its directory."""
63+
host_dir = info.root / VERIFY_HOST_RELPATH
64+
if fresh and host_dir.exists():
65+
shutil.rmtree(host_dir)
66+
if not (host_dir / "pyproject.toml").is_file():
67+
create_host(
68+
host_dir,
69+
name="verify_host",
70+
modules=[],
71+
framework_version=resolve_framework_version(),
72+
)
73+
_wire_module_dep(host_dir / "pyproject.toml", info.pypi_name)
74+
return host_dir
75+
76+
77+
def _wire_module_dep(pyproject_path: Path, pypi_name: str) -> None:
78+
"""Add the module as an editable path dependency of the verify host."""
79+
text = pyproject_path.read_text(encoding="utf-8")
80+
# Unpinned on purpose: [tool.uv.sources] overrides it with the local path.
81+
# Note: pin_framework_deps may have rewritten the array onto one line, so
82+
# match the opening bracket only — TOML tolerates the injected newline.
83+
text = text.replace("dependencies = [", f'dependencies = [\n "{pypi_name}",', 1)
84+
text += f'\n[tool.uv.sources]\n{pypi_name} = {{ path = "../..", editable = true }}\n'
85+
pyproject_path.write_text(text, encoding="utf-8")

0 commit comments

Comments
 (0)