diff --git a/.dockerignore b/.dockerignore
index 8a207bac..b4ccbaaf 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,6 +2,14 @@
**/bin/
**/obj/
**/node_modules/
+**/.vite/
+
+# Vite output directories — regenerated from source by `npm run build` in
+# Dockerfile stage 4. Excluding these prevents stale local build artifacts
+# (e.g. files left over from a previous `assetFileNames` convention) from
+# being baked into the image.
+modules/*/src/SimpleModule.*/wwwroot/
+template/SimpleModule.Host/wwwroot/js/
# Version control
.git/
diff --git a/Directory.Build.props b/Directory.Build.props
index 411d56d4..48d3d6b1 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -36,6 +36,17 @@
true
true
+
+
+
+
latest-all
diff --git a/Makefile b/Makefile
index e406833e..fb3b6255 100644
--- a/Makefile
+++ b/Makefile
@@ -276,6 +276,14 @@ ci-full: check build-all test-all ## Full CI: lint, build (.NET + JS), all tests
docker-build: ## Build the Docker image
docker build -t simplemodule .
+.PHONY: docker-build-nocache
+docker-build-nocache: ## Build the Docker image with no layer cache (guaranteed fresh)
+ docker build --no-cache -t simplemodule .
+
+.PHONY: docker-clean-run
+docker-clean-run: docker-build ## Build a fresh Docker image and start the stack
+ docker compose up -d --force-recreate
+
.PHONY: docker-up
docker-up: ## Start all Docker Compose services
docker compose up -d
@@ -295,9 +303,18 @@ docker-ps: ## Show running Docker Compose services
# ─── Clean ───────────────────────────────────────
.PHONY: clean
-clean: ## Clean .NET build outputs
+clean: ## Clean .NET + Vite build outputs, static asset manifests, and Vite caches
dotnet clean
+ @echo "Removing bin/ and obj/ directories..."
find . -type d \( -name bin -o -name obj \) -not -path './node_modules/*' -exec rm -rf {} + 2>/dev/null || true
+ @echo "Removing module wwwroot build outputs (Vite does not clean these — emptyOutDir is false)..."
+ find modules -type f \( -name '*.mjs' -o -name '*.mjs.map' -o -name '*.js' -o -name '*.js.map' -o -name '*.css' -o -name '*.css.map' \) -path '*/src/SimpleModule.*/wwwroot/*' -delete 2>/dev/null || true
+ @echo "Removing ClientApp bundle..."
+ rm -f $(HOST_PROJECT)/wwwroot/js/app.js $(HOST_PROJECT)/wwwroot/js/app.js.map
+ @echo "Removing Vite caches..."
+ find . -type d -name '.vite' -not -path './node_modules/*' -exec rm -rf {} + 2>/dev/null || true
+ rm -rf node_modules/.vite 2>/dev/null || true
+ @echo "Clean complete."
.PHONY: clean-js
clean-js: ## Remove node_modules and JS build outputs
@@ -308,6 +325,10 @@ clean-js: ## Remove node_modules and JS build outputs
clean-all: clean clean-js db-reset ## Full clean (.NET + JS + database)
@echo "All build artifacts and database removed."
+.PHONY: clean-run
+clean-run: clean build-js ## Clean everything, rebuild all JS, then run the host
+ dotnet run --project $(HOST_PROJECT)
+
.PHONY: pristine
pristine: clean-all setup ## Clean everything and reinstall from scratch
diff --git a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs
index 6581efbd..0406cf2c 100644
--- a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs
+++ b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs
@@ -1,3 +1,4 @@
+using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
@@ -13,6 +14,7 @@ public sealed class HtmlFileInertiaPageRenderer : IInertiaPageRenderer
private const string PagePlaceholder = "";
private const string NoncePlaceholder = "";
private const string VersionPlaceholder = "";
+ private const string ModuleCssPlaceholder = "";
private readonly string _beforePlaceholder;
private readonly string _afterPlaceholder;
@@ -34,6 +36,16 @@ public HtmlFileInertiaPageRenderer(IWebHostEnvironment env)
StringComparison.Ordinal
);
+ // Inject tags for every module RCL that ships its own CSS file
+ // (e.g. _content/SimpleModule.PageBuilder/pagebuilder.css). Discovered
+ // once at startup by walking the WebRootFileProvider, which sees both
+ // the host's physical wwwroot and every RCL's static web assets.
+ html = html.Replace(
+ ModuleCssPlaceholder,
+ BuildModuleCssLinks(env, InertiaMiddleware.Version),
+ StringComparison.Ordinal
+ );
+
var idx = html.IndexOf(PagePlaceholder, StringComparison.Ordinal);
if (idx < 0)
throw new InvalidOperationException(
@@ -81,6 +93,41 @@ public Task RenderPageAsync(HttpContext httpContext, string pageJson)
);
}
+ private static string BuildModuleCssLinks(IWebHostEnvironment env, string version)
+ {
+ var contents = env.WebRootFileProvider.GetDirectoryContents("_content");
+ if (!contents.Exists)
+ return string.Empty;
+
+ var sb = new StringBuilder();
+ foreach (var entry in contents)
+ {
+ if (
+ !entry.IsDirectory
+ || !entry.Name.StartsWith("SimpleModule.", StringComparison.Ordinal)
+ )
+ continue;
+
+ // Module Vite builds emit CSS as {assembly}.lowercase().css by convention
+ // (see define-module-config.ts assetFileNames). The URL must match the
+ // on-disk filename, so ToLowerInvariant is the correct behavior here, not
+ // the security-focused ToUpperInvariant that CA1308 suggests.
+#pragma warning disable CA1308
+ var cssFileName = entry.Name.ToLowerInvariant() + ".css";
+#pragma warning restore CA1308
+ var cssPath = $"_content/{entry.Name}/{cssFileName}";
+ if (!env.WebRootFileProvider.GetFileInfo(cssPath).Exists)
+ continue;
+
+ sb.Append("");
+ }
+ return sb.ToString();
+ }
+
private static string TransformForViteDev(string html)
{
var importMapStart = html.IndexOf("