Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ jobs:
- run: make install-js
- run: make gen-pages
- run: make build
# These drive a real gen-pages + vite build to prove module-shipped CSS
# and cross-module npm-name imports actually resolve. They self-skip
# without node_modules, so the `Python tests` job (make install-py only)
# silently skips them — this is the one job that can really run them.
- name: Module asset build guards
run: uv run pytest framework/cli/tests/test_module_css_build.py

e2e-smoke:
name: E2E smoke (Playwright)
Expand Down
115 changes: 101 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/) post-1.0.

> **Coverage note.** Between 0.0.1 and 0.0.27 this file carried no per-version
> sections, and several already-released fixes sat under `[Unreleased]` long
> after shipping — which led a downstream app to conclude a fix it depended on
> was still unreleased (GH issue #253). Those entries have been moved to the
> release they actually shipped in, verified with `git tag --contains`.
> Versions not listed below still have no entry; consult the commit log.

## [Unreleased]

### Added
Expand All @@ -21,26 +28,102 @@ All notable changes to this project are documented in this file. The format is b
production-mode containers pass `UsersSettings` boot validation.

### Fixed
- Vite's dev-mode dependency pre-bundling now resolves cross-package bare
imports (e.g. `maplibre-gl`, `pmtiles`) from module pages whose importers sit
outside the host's `client_app/` — including wheel-installed modules and
workspace modules shipping their own JS deps. The scaffold template (Vite 6)
seeds `optimizeDeps.esbuildOptions.nodePaths` and the framework repo (Vite 8)
seeds `optimizeDeps.rolldownOptions.resolve.modules` with the workspace
`node_modules/` as a NODE_PATH-style fallback for the dep scanner (GH issue #152).
- `smpy gen-pages` now emits module stylesheet `@import` lines as **absolute
paths** instead of `#module/<pkg>` alias specifiers. The alias only resolved
if the host's `vite.config.ts` defined a matching `resolve.alias` — but that
file is scaffold output, written into an app once and then owned and edited
there, so it is versioned independently of these Python packages. Upgrading
`simple_module_*` 0.0.26 → 0.0.27 therefore broke `vite build` in every app
scaffolded earlier, failing with `Can't resolve '#module/<pkg>/styles.css'` —
naming a specifier that appears nowhere in the app's own sources.

`modules.generated.css` is now self-contained: it resolves under any
`vite.config.ts`, with no alias configured at all, exactly as the `@source`
lines in the same file already did. **No host action is required** — upgrade
and re-run `gen-pages`. The scaffold template still defines the
`#module/<pkg>` alias for hand-written imports, but nothing generated depends
on it any more (GH issue #253).

### Added
- A module can now import another module's TS/TSX by npm package name:
`import x from '@simple-module-py/pagebuilder/components/blockRegistry'`.
Nothing in Node's own resolution made this work — a wheel-installed module is
not an npm workspace member, so it never lands in `node_modules` at all;
a workspace member *is* symlinked, but onto the source-tree module root, one
level above the Python package, so subpaths landed somewhere nonexistent.

`gen-pages` now records each module's `npm_name` in `modules.assets.json`,
and the host aliases it onto the module's **Python package directory**. That
anchor is forced, not chosen: a wheel ships `site-packages/<pkg>/**` and
nothing above it, so the module root does not survive installation and the
package directory is the only anchor both layouts share. The practical
consequence is that the subpath is relative to the *package*:

```tsx
import x from '@simple-module-py/foo/components/Widget'; // ✅ both layouts
import x from '@simple-module-py/foo/foo/components/Widget'; // ❌ workspace-only
```

If you previously hand-rolled this alias against the module root, drop the
duplicated path segment. **Existing hosts need a `vite.config.ts` change** —
unlike the CSS fix above, the import lives in module source rather than a
generated file, so it cannot be made self-contained. In the loop over
`modules.assets.json` entries, add:

```ts
if (entry.npm_name) {
moduleAliases.push({ find: entry.npm_name, replacement: entry.package });
}
```

and skip those names when collecting `optimizeDeps.include` — they resolve to
source directories, not to pre-bundlable packages (GH issue #253).

## [0.0.27] — 2026-08-06

### Known issue
- Fixed in the `[Unreleased]` entry above. `gen-pages` emitted
`@import "#module/<pkg>/…"` into `modules.generated.css`, which resolves only
in hosts scaffolded at 0.0.27 or later; apps scaffolded earlier fail
`vite build` after a Python-only upgrade. Either upgrade past this release,
or add the alias to `host/client_app/vite.config.ts` by hand — build
`{ find: '#module/' + package_name, replacement: package }` from each entry
in `client_app/modules.assets.json` and pass the list as `resolve.alias`
(GH issue #253).

## [0.0.16] — 2026-05-25

### Fixed
- The `users` module's post-login redirect (`login_redirect_url`) no longer
hard-codes a `/` fallback when the Dashboard module isn't installed — `/`
404s on apps without a root route (e.g. `smpy_gis`, `--preset minimal`). It
now redirects to the first sibling module that exposes view routes, falling
back to `/` only as an absolute last resort. Operator-set overrides are
always preserved (GH issue #173).

## [0.0.15] — 2026-05-21

### Fixed
- The `moduleBareImportResolver` Vite plugin no longer short-circuits on
`fsRoot`/`projectRoot` containment, so workspace-member modules at
`modules/<name>/<pkg>/pages/` get the same workspace-root re-resolution as
wheel-installed modules. In an npm-workspaces layout the workspace root *is*
the resolver root, so the previous early-return excluded the very modules
that need it. Cross-package bare imports (`maplibre-gl`, `pmtiles`, peer
deps) now resolve in both wheel and workspace install modes (GH issue #156).
- The `users` module's post-login redirect (`login_redirect_url`) no longer
hard-codes a `/` fallback when the Dashboard module isn't installed — `/`
404s on apps without a root route (e.g. `smpy_gis`, `--preset minimal`). It
now redirects to the first sibling module that exposes view routes, falling
back to `/` only as an absolute last resort. Operator-set overrides are
always preserved (GH issue #173).
- The framework repo (Vite 8) seeds
`optimizeDeps.rolldownOptions.resolve.modules` with the workspace
`node_modules/` as a NODE_PATH-style fallback for the dep scanner
(GH issue #155).

## [0.0.13] — 2026-05-15

### Fixed
- Vite's dev-mode dependency pre-bundling now resolves cross-package bare
imports (e.g. `maplibre-gl`, `pmtiles`) from module pages whose importers sit
outside the host's `client_app/`. The scaffold template (Vite 6) seeds
`optimizeDeps.esbuildOptions.nodePaths` with the workspace `node_modules/`
as a NODE_PATH-style fallback for the dep scanner (GH issue #152).

## [0.0.1] — 2026-04-21

Expand Down Expand Up @@ -73,5 +156,9 @@ Initial public release. All 12 Python packages publish to PyPI and all 3 JS pack
- PyPI Trusted Publishing workflow (`.github/workflows/release.yml`) for zero-secret releases.
- npm Trusted Publishing for all three JS packages.

[Unreleased]: https://github.com/antosubash/simple_module_python/compare/v0.0.1...HEAD
[Unreleased]: https://github.com/antosubash/simple_module_python/compare/v0.0.27...HEAD
[0.0.27]: https://github.com/antosubash/simple_module_python/compare/v0.0.26...v0.0.27
[0.0.16]: https://github.com/antosubash/simple_module_python/compare/v0.0.15...v0.0.16
[0.0.15]: https://github.com/antosubash/simple_module_python/compare/v0.0.14...v0.0.15
[0.0.13]: https://github.com/antosubash/simple_module_python/compare/v0.0.12...v0.0.13
[0.0.1]: https://github.com/antosubash/simple_module_python/releases/tag/v0.0.1
4 changes: 3 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"modules/*/*/pages/**",
"modules/*/*/components/**",
"!host/client_app/modules.generated.ts",
"!host/client_app/modules.manifest.json"
"!host/client_app/modules.manifest.json",
"!host/client_app/modules.generated.css",
"!host/client_app/modules.assets.json"
],
"ignoreUnknown": true
},
Expand Down
81 changes: 69 additions & 12 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,16 @@ Modules may ship TSX pages in `my_module/pages/*.tsx`. On host boot (and on
- `client_app/modules.generated.css` — Tailwind `@source` entries, plus an
`@import` per module-shipped stylesheet (see [Styling](#styling))
- `client_app/modules.assets.json` — the per-module asset record that
`vite.config.ts` builds its `#module/<pkg>` aliases from
`vite.config.ts` builds its `server.fs.allow` entries (and the optional
`#module/<pkg>` aliases) from

Vite's `server.fs.allow` is extended to cover each installed module's
package root, so pages shipped inside a wheel work for the dev server and
production build alike.

**Inertia pages never need pre-bundling.** The consuming host's Vite build
compiles `pages/*.tsx` straight out of the installed wheel (via the
`#module/<pkg>` aliases + `server.fs.allow`). `static/dist/` +
compiles `pages/*.tsx` straight out of the installed wheel (via
`modules.generated.ts` + `server.fs.allow`). `static/dist/` +
`static_mounts()` exist only for assets *outside* that pipeline — vendor JS,
standalone widgets, images. Build them with `smpy module build` (see
[Developing out-of-tree](#developing-out-of-tree)) and expose them via
Expand All @@ -272,6 +273,53 @@ class MyModule(ModuleBase):

The host mounts each entry as `StaticFiles` during boot.

### Importing another module's TS/TSX

Use the sibling's **npm package name** — the `name` in its `package.json`:

```tsx
import { BlockRegistry } from '@simple-module-py/pagebuilder/components/blockRegistry';
```

The host builds that alias from `modules.assets.json` and points it at the
sibling's **Python package directory** (`my_module/`). So everything after the
package name is a path *inside* that package — `components/…`, `pages/…`,
mirroring the layout in [Anatomy of a module package](#anatomy-of-a-module-package).

That anchor is forced rather than chosen, and it is worth understanding because
the obvious alternative silently half-works:

| | wheel install | workspace / editable |
|---|---|---|
| Python package | `site-packages/foo/` | `modules/foo/foo/` |
| `package.json` | `site-packages/foo/package.json` | `modules/foo/package.json` |
| module root | **does not exist** | `modules/foo/` |

A wheel ships `site-packages/foo/**` and nothing above it — Hatch force-includes
the module-root `package.json` *into* the package. The source-tree module root
therefore does not survive installation, and the Python package directory is the
only anchor both layouts share.

This means the shape npm gives you for a workspace member is the wrong one:

```tsx
// ✅ same file in both layouts
import x from '@simple-module-py/foo/components/Widget';

// ❌ workspace-only. npm symlinks @simple-module-py/foo -> modules/foo/, so
// this happens to resolve in a checkout and breaks once foo is wheel-installed.
import x from '@simple-module-py/foo/foo/components/Widget';
```

Declare the sibling in your `package.json` `peerDependencies` so the dependency
is explicit. Nothing pre-bundles it — it resolves to source, not to a
node_modules package.

Note this is the one part of module frontend wiring that *does* depend on the
host's `vite.config.ts`, because the import lives in your source rather than in
a generated file. Apps scaffolded before this shipped need the alias block added
— see the CHANGELOG entry for the diff.

## Styling

A module may ship two optional stylesheets beside its `pages/` directory.
Expand All @@ -290,16 +338,25 @@ my_module/
`client_app/modules.generated.css`:

```css
@import "#module/my_module/theme.css";
@import "#module/my_module/styles.css" layer(components);
@import "/abs/path/to/site-packages/my_module/theme.css";
@import "/abs/path/to/site-packages/my_module/styles.css" layer(components);
```

**Nothing needs to be added to the host's `styles.css` by hand.** The
`#module/<pkg>` specifier is a Vite alias built from `modules.assets.json`,
so it resolves identically whether the module is a workspace member or
installed from a wheel — and no generated file ends up containing a
`../../../.venv/lib/python3.12/site-packages/...` path that would break the
next time the interpreter version changes.
**Nothing needs to be added to the host's `styles.css` by hand, and nothing
needs to be added to its `vite.config.ts` either.** The paths are absolute —
resolved through `importlib.resources`, exactly like the `@source` entries in
the same file — so they resolve identically whether the module is a workspace
member or installed from a wheel, under any host config.

That last part is load-bearing. `modules.generated.css` is regenerated from
whatever Python packages are installed, but `vite.config.ts` is *scaffold
output*: written into an app once and then owned and edited there. The two are
versioned independently, so anything the generated file emits must resolve
without host cooperation. Emitting a `#module/<pkg>` alias specifier instead
broke exactly this way — upgrading the Python packages alone made the build
fail on a specifier the app had never written ([#253]).

[#253]: https://github.com/antosubash/simple_module_python/issues/253

Imports are emitted in module discovery order, which is topological by
`ModuleMeta.depends_on`. A module that depends on another can therefore
Expand Down Expand Up @@ -372,7 +429,7 @@ npm run typecheck # tsc --noEmit over your pages

`tsc` alone cannot tell you whether your pages and `theme.css`/`styles.css`
survive a real host build (Vite import resolution, Tailwind scanning, the
`#module/<pkg>` alias plumbing). `verify` answers that by scaffolding a
`gen-pages` CSS emission). `verify` answers that by scaffolding a
throwaway host into `.smpy/verify-host/` (cached, gitignored), installing
your module into it as an editable path dependency, and running the host's
real `gen-pages` + `npm run build`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,35 @@ if (fs.existsSync(manifestPath)) {
}
}

// Per-module aliases, so generated CSS can say
// @import "#module/gis/styles.css"
// instead of a ../../../.venv/lib/python3.12/site-packages/gis/styles.css
// path that breaks the moment the interpreter version changes.
// Three things come out of modules.assets.json.
//
// 1. `server.fs.allow` entries. The dev server must be allowed to read each
// module's package dir. Read from modules.assets.json rather than
// modules.manifest.json because the manifest is keyed off `pages/`, so a
// module shipping only CSS never appears in it.
//
// 2. A convenience `#module/<pkg>` alias. This is NOT required by
// `modules.generated.css` — that file imports module stylesheets by
// absolute path, so it resolves with no alias configured at all. Emitting
// an alias there made a generated file depend on this hand-owned config,
// and since `vite.config.ts` is scaffolded once and then owned by the app,
// a Python-only version bump broke every host scaffolded earlier
// (GH issue #253). The alias stays because it costs nothing.
//
// 3. An `<npm_name>` alias per module, so one module can import another's
// TS/TSX by package name. Aimed at the module's *Python package* dir —
// a wheel ships `site-packages/foo/**` and nothing above it, so the
// source-tree module root is not a target both layouts have. Needed in
// both: a wheel module is never in node_modules, and npm symlinks a
// workspace member onto the module root, one level too high.
// See docs/module-authoring.md § Importing another module's TS/TSX.
//
// `@tailwindcss/vite` builds its CSS import resolver with
// `createResolver({ ...config.resolve, ... })`, so `resolve.alias` governs
// CSS `@import` as well as JS — verified against @tailwindcss/vite 4.2.4.
//
// Read from modules.assets.json rather than modules.manifest.json: the
// manifest is keyed off `pages/`, so a module shipping only CSS never
// appears in it.
type ModuleAsset = { package_name: string; package: string };
type ModuleAsset = { package_name: string; package: string; npm_name?: string | null };
const moduleAliases: { find: string; replacement: string }[] = [];
const moduleNpmNames = new Set<string>();
const assetsPath = path.resolve(__dirname, 'modules.assets.json');
let moduleAssets: Record<string, ModuleAsset> = {};
try {
Expand All @@ -92,6 +107,10 @@ try {
}
for (const entry of Object.values(moduleAssets)) {
moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package });
if (entry.npm_name) {
moduleAliases.push({ find: entry.npm_name, replacement: entry.package });
moduleNpmNames.add(entry.npm_name);
}
if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package);
}
// Keep the alias list in a stable, longest-first order. Vite matches a string
Expand Down Expand Up @@ -169,6 +188,10 @@ function collectOptimizeIncludes(): string[] {
for (const block of [pkg.dependencies, pkg.peerDependencies]) {
for (const name of Object.keys(block ?? {})) {
if (name.startsWith('@types/')) continue;
// A sibling module declared as a dep/peer dep is not a node_modules
// package — it is aliased to a source directory above. Pre-bundling
// it would point the optimizer at raw .tsx with no entry point.
if (moduleNpmNames.has(name)) continue;
const nested = findPackageJSON(name);
if (!nested) continue;
const nestedPkg = readPackageJSON(nested);
Expand Down Expand Up @@ -231,8 +254,8 @@ export default defineConfig({
plugins: [moduleBareImportResolver(), react(), tailwindcss()],
root: __dirname,
resolve: {
// `#module/<pkg>` -> that module's package directory. Consumed by the
// @import lines in modules.generated.css.
// `#module/<pkg>` -> that module's package directory. Optional sugar for
// hand-written imports; modules.generated.css does not rely on it.
alias: moduleAliases,
dedupe: [...REACT_CORE_DEPS, '@simple-module-py/ui', '@simple-module-py/i18n'],
},
Expand Down
Loading
Loading