Skip to content
Open
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ jobs:
- name: Test
run: dotnet test --no-build

# SimpleModule.Cli.Tests sets IsTestProject=false (its scaffolding tests
# spawn `dotnet build` and take ~15min), so `dotnet test` above skips the
# whole project. Run the fast, pure template tests explicitly — otherwise
# they guard nothing.
- name: Test CLI templates
run: >-
dotnet run --project tests/SimpleModule.Cli.Tests --no-build --
-class SimpleModule.Cli.Tests.HostTemplatesAppCssTests

vulnerable-packages:
runs-on: ubuntu-latest
needs: lint
Expand Down
7 changes: 7 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@
<SimpleModuleThemeCss>$(RepoRoot)packages/SimpleModule.Theme.Default/theme.css</SimpleModuleThemeCss>
<SimpleModuleModulesDir>$(RepoRoot)modules</SimpleModuleModulesDir>
<SimpleModuleRoutesOutput>$(RepoRoot)packages/SimpleModule.Client/src/routes.ts</SimpleModuleRoutesOutput>
<SimpleModuleUiDir>$(RepoRoot)packages/SimpleModule.UI</SimpleModuleUiDir>
<SimpleModuleClientDir>$(RepoRoot)packages/SimpleModule.Client</SimpleModuleClientDir>
</PropertyGroup>
<!-- The monorepo's app.css has one @source root with no consumer equivalent. Tracking
it through the extension point keeps the Tailwind up-to-date check complete (#288). -->
<ItemGroup Condition="Exists('$(MSBuildThisFileDirectory)docs/design-system')">
<TailwindExtraSourceFiles Include="$(RepoRoot)docs/design-system/**/*" />
</ItemGroup>
<!-- .NET SDK analyzers only work on net5.0+ -->
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
<AnalysisLevel>latest-all</AnalysisLevel>
Expand Down
14 changes: 12 additions & 2 deletions cli/SimpleModule.Cli/Templates/HostTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public static string ClientAppPackageJson(string projectName)

/// <summary>
/// Return a clean Styles/app.css with tailwindcss import, theme import,
/// and @source directives for modules. Strip _scan/ import if present.
/// and @source directives for modules.
/// </summary>
/// <remarks>
/// The embedded template lives in <c>template/SimpleModule.Host/Styles/</c> and uses
Expand All @@ -176,14 +176,24 @@ public static string ClientAppPackageJson(string projectName)
/// <item>Framework packages live in <c>node_modules/@simplemodule/*</c> at the project root
/// (3 ups from Styles/) — installed via npm.</item>
/// <item>Modules live at <c>src/modules/</c> (2 ups from Styles/).</item>
/// <item><c>@source "./_scan/"</c> is already relative to Styles/ and is kept as-is:
/// SimpleModule.Hosting.targets stages every module's built <c>.pages.js</c> there, and
/// that is the only way Tailwind sees classes from packaged modules (and from module
/// components that live outside <c>Pages/</c>). Stripping it left scaffolded apps
/// missing those utility classes (#290).</item>
/// <item><c>@source "../../../docs/design-system/"</c> is dropped: it is a monorepo-only
/// root with no consumer equivalent (the monorepo tracks it through
/// <c>@(TailwindExtraSourceFiles)</c>), so in a scaffold it would dangle at a
/// directory that never exists — and would silently start scanning unrelated files
/// if one were ever created there.</item>
/// </list>
/// We rewrite each <c>@import</c>/<c>@source</c> directive directly rather than blindly
/// rewriting path prefixes, since the original substrings overlap.
/// </remarks>
public static string AppCss()
{
var lines = EmbeddedResourceReader.ReadTemplateLines("Templates.Host.Styles.app.css");
lines.RemoveAll(line => line.Contains("_scan/", StringComparison.Ordinal));
lines.RemoveAll(line => line.Contains("docs/design-system", StringComparison.Ordinal));

var result = new List<string>(lines.Count);
foreach (var line in lines)
Expand Down
15 changes: 12 additions & 3 deletions docs/site/advanced/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,14 @@ You must build the .NET project before running type generation, since the tool r

## Output Location

Each module gets its own `types.ts` file at:
Each module gets its own `types.ts` file inside its primary source project, so the
generated types sit next to the `Pages/` that consume them and are covered by that
project's `tsconfig.json`. The project directory is resolved from what is on disk,
which differs between layouts:

```
modules/{ModuleName}/src/SimpleModule.{ModuleName}/types.ts
modules/{ModuleName}/src/SimpleModule.{ModuleName}/types.ts # framework repo
src/modules/{ModuleName}/src/{ModuleName}/types.ts # sm new module
```

For example, the Customers module produces:
Expand All @@ -136,6 +140,10 @@ For example, the Customers module produces:
modules/Customers/src/SimpleModule.Customers/types.ts
```

Modules that arrived as NuGet packages are skipped — they have no source project in
your repo, so nothing is written and no directory is created for them. The tool
reports how many it skipped.

The file is marked as auto-generated and should not be edited manually:

```typescript
Expand Down Expand Up @@ -264,7 +272,8 @@ The `extract-ts-types.mjs` tool then:
1. Reads all `DtoTypeScript_*.g.cs` files from the generated output directory
2. Extracts the module name from the `// @module` comment
3. Parses the TypeScript interfaces from the comment block
4. Writes a `types.ts` file to the module's source directory
4. Locates the module's existing source project, and writes a `types.ts` into it —
skipping the module when it has no source project locally

Property names are automatically converted from `PascalCase` (C#) to `camelCase` (TypeScript) during generation, matching the default `System.Text.Json` serialization behavior.

Expand Down
11 changes: 7 additions & 4 deletions docs/site/frontend/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ export async function resolvePage(name: string) {

For a route name like `Customers/Browse`:
1. The module name `Customers` is extracted from the first segment
2. The bundle `/_content/SimpleModule.Customers/SimpleModule.Customers.pages.js` is dynamically imported
3. The `pages` record is looked up for the key `Customers/Browse`
4. Lazy entries (functions) are resolved, eager entries are returned directly
2. The assembly serving that module is read from the `<script data-module-assemblies>` map the server renders into the page shell
3. The bundle `/_content/{assembly}/{assembly}.pages.js` is dynamically imported
4. The `pages` record is looked up for the key `Customers/Browse`
5. Lazy entries (functions) are resolved, eager entries are returned directly

A module's static web assets are served under its RCL `AssemblyName`, which is not derivable from the module name — framework modules build as `SimpleModule.Customers`, while `sm new module` scaffolds a bare `Customers`. The server declares the mapping so the first request goes to the path that actually serves the bundle; if the map is missing (an older page shell), the resolver falls back to trying both forms.

A cache-buster query parameter is appended from a `<meta name="cache-buster">` tag when present, ensuring browsers pick up new builds without stale caches.

Expand All @@ -122,7 +125,7 @@ The `@simplemodule/client` package (`packages/SimpleModule.Client/`) provides th

## Type Safety

The source generator discovers C# types marked with the `[Dto]` attribute and embeds TypeScript interface definitions. The `scripts/extract-ts-types.mjs` script extracts these into `.ts` files under `ClientApp/types/`, giving React components full type safety over server-provided props:
The source generator discovers C# types marked with the `[Dto]` attribute and embeds TypeScript interface definitions. The `scripts/extract-ts-types.mjs` script extracts these into a `types.ts` in each module's own source project, next to its `Pages/`, giving React components full type safety over server-provided props:

```tsx
import type { Customer } from '../types';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using System.Reflection;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SimpleModule.Core;
using SimpleModule.Core.Inertia;
using SimpleModule.Core.Security;
using SimpleModule.DevTools;
Expand All @@ -24,9 +27,18 @@ public sealed class HtmlFileInertiaPageRenderer : IInertiaPageRenderer
private readonly string _afterPlaceholderViteDev;
private readonly bool _isDevelopment;
private readonly bool _hasHeadPlaceholder;
private readonly string _moduleAssembliesScript;

public HtmlFileInertiaPageRenderer(IWebHostEnvironment env)
public HtmlFileInertiaPageRenderer(IWebHostEnvironment env, IEnumerable<IModule> modules)
{
// A module's static web assets are served under its RCL AssemblyName, which is
// not derivable from the module name: framework modules build as
// SimpleModule.<Module>, while `sm new module` scaffolds a bare <Module>. The
// client used to guess and eat a 404 per module on every page load (#287), so
// hand it the mapping the server already knows.
var moduleAssemblies = BuildModuleAssemblyMap(modules);
_moduleAssembliesScript = BuildModuleAssembliesScript(moduleAssemblies);

var path = Path.Combine(env.WebRootPath, "index.html");
var html = File.ReadAllText(path);

Expand All @@ -45,7 +57,7 @@ public HtmlFileInertiaPageRenderer(IWebHostEnvironment env)
// the host's physical wwwroot and every RCL's static web assets.
html = html.Replace(
ModuleCssPlaceholder,
BuildModuleCssLinks(env, InertiaMiddleware.Version),
BuildModuleCssLinks(env, InertiaMiddleware.Version, moduleAssemblies.Values),
StringComparison.Ordinal
);

Expand Down Expand Up @@ -110,6 +122,7 @@ public async Task RenderPageAsync(HttpContext httpContext, string pageJson)
await httpContext.Response.WriteAsync(
string.Concat(
before.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal),
_moduleAssembliesScript.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal),
$"<script data-page=\"app\" type=\"application/json\" nonce=\"{nonce}\">{pageJson}</script>",
devScript,
after.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal)
Expand Down Expand Up @@ -152,18 +165,63 @@ private static async Task<string> BuildHeadContributionsAsync(HttpContext httpCo
return sb?.ToString() ?? string.Empty;
}

private static string BuildModuleCssLinks(IWebHostEnvironment env, string version)
/// <summary>
/// Maps each module's <c>[Module]</c> name to the assembly its static web assets
/// are served under. The Inertia page name's first segment is the module name, so
/// this is what the client needs to build a bundle URL that resolves.
/// </summary>
private static Dictionary<string, string> BuildModuleAssemblyMap(IEnumerable<IModule> modules)
{
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var module in modules)
{
var type = module.GetType();
var name = type.GetCustomAttribute<ModuleAttribute>()?.Name;
var assembly = type.Assembly.GetName().Name;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(assembly))
continue;

map[name] = assembly;
}
return map;
}

private static string BuildModuleAssembliesScript(Dictionary<string, string> moduleAssemblies)
{
if (moduleAssemblies.Count == 0)
return string.Empty;

// The default encoder escapes '<', '>' and '&', so a module or assembly name
// can never break out of the <script> element.
var json = JsonSerializer.Serialize(moduleAssemblies);

return $"<script data-module-assemblies type=\"application/json\" nonce=\"{NoncePlaceholder}\">{json}</script>";
}

private static string BuildModuleCssLinks(
IWebHostEnvironment env,
string version,
IEnumerable<string> moduleAssemblies
)
{
var contents = env.WebRootFileProvider.GetDirectoryContents("_content");
if (!contents.Exists)
return string.Empty;

// An RCL serves its assets under its AssemblyName, so a module scaffolded as a
// bare <Module> (rather than SimpleModule.<Module>) lands in a directory the
// prefix check alone would skip — and its stylesheet would never be linked.
var known = new HashSet<string>(moduleAssemblies, StringComparer.Ordinal);

var sb = new StringBuilder();
foreach (var entry in contents)
{
if (!entry.IsDirectory)
continue;

if (
!entry.IsDirectory
|| !entry.Name.StartsWith("SimpleModule.", StringComparison.Ordinal)
!entry.Name.StartsWith("SimpleModule.", StringComparison.Ordinal)
&& !known.Contains(entry.Name)
)
continue;

Expand Down
50 changes: 47 additions & 3 deletions framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
<SimpleModuleRoutesOutput Condition="'$(SimpleModuleRoutesOutput)' == ''"
>$(MSBuildProjectDirectory)/ClientApp/routes.ts</SimpleModuleRoutesOutput
>
<SimpleModuleUiDir Condition="'$(SimpleModuleUiDir)' == ''"
>$(RepoRoot)node_modules/@simplemodule/ui</SimpleModuleUiDir
>
<SimpleModuleClientDir Condition="'$(SimpleModuleClientDir)' == ''"
>$(RepoRoot)node_modules/@simplemodule/client</SimpleModuleClientDir
>
</PropertyGroup>
<!-- Build configuration -->
<PropertyGroup>
Expand All @@ -38,20 +44,50 @@
<ExtractDtoStamp>$(IntermediateOutputPath)extractdto.stamp</ExtractDtoStamp>
<ExtractRoutesStamp>$(IntermediateOutputPath)extractroutes.stamp</ExtractRoutesStamp>
</PropertyGroup>
<!-- Incremental Tailwind: track CSS source files + theme + Razor layouts -->
<!-- Incremental Tailwind: the up-to-date check has to see every file Tailwind
scans, not just the stylesheets. Styles/app.css declares the module pages, the
UI and client packages, and the design system as @source roots; unless those are
inputs here, editing one leaves the compiled CSS untouched and its utility
classes are silently never generated (#288). Globs are anchored rather than
rooted at ** so evaluation does not walk every module's bin/ and obj/. Hosts with
@source directives of their own can add to @(TailwindExtraSourceFiles). -->
<ItemGroup Condition="Exists('$(TailwindInput)')">
<TailwindSourceFiles
Include="$(MSBuildProjectDirectory)/Styles/**/*.css"
Exclude="$(MSBuildProjectDirectory)/Styles/_scan/**"
/>
<TailwindSourceFiles Include="$(SimpleModuleThemeCss)" Condition="Exists('$(SimpleModuleThemeCss)')" />
<TailwindSourceFiles
Include="$(SimpleModuleModulesDir)/*/src/*/Pages/**/*.tsx;$(SimpleModuleModulesDir)/*/src/*/Views/**/*.tsx"
Condition="Exists('$(SimpleModuleModulesDir)')"
/>
<!-- These two are declared as directory @source roots, so Tailwind scans every
source extension under them — not just TypeScript. Track the JS forms as well
or a utility class added in a .js/.jsx file there never reaches the output. -->
<TailwindSourceFiles
Include="$(SimpleModuleUiDir)/**/*.tsx;$(SimpleModuleUiDir)/**/*.ts;$(SimpleModuleUiDir)/**/*.jsx;$(SimpleModuleUiDir)/**/*.js"
Exclude="$(SimpleModuleUiDir)/node_modules/**"
Condition="Exists('$(SimpleModuleUiDir)')"
/>
<TailwindSourceFiles
Include="$(SimpleModuleClientDir)/**/*.tsx;$(SimpleModuleClientDir)/**/*.ts;$(SimpleModuleClientDir)/**/*.jsx;$(SimpleModuleClientDir)/**/*.js"
Exclude="$(SimpleModuleClientDir)/node_modules/**"
Condition="Exists('$(SimpleModuleClientDir)')"
/>
<TailwindSourceFiles
Include="$(MSBuildProjectDirectory)/ClientApp/**/*.tsx;$(MSBuildProjectDirectory)/ClientApp/**/*.ts"
Exclude="$(MSBuildProjectDirectory)/ClientApp/node_modules/**"
Condition="Exists('$(MSBuildProjectDirectory)/ClientApp')"
/>
</ItemGroup>
<!-- Incremental Vite: track ClientApp source files -->
<ItemGroup Condition="Exists('ClientApp/package.json') And (Exists('ClientApp/node_modules') Or Exists('$(RepoRoot)node_modules'))">
<ViteSourceFiles Include="$(MSBuildProjectDirectory)/ClientApp/**/*.ts;$(MSBuildProjectDirectory)/ClientApp/**/*.tsx" />
<ViteSourceFiles Include="$(MSBuildProjectDirectory)/ClientApp/package.json" />
</ItemGroup>
<!-- Collect module .pages.js assets for Tailwind CSS scanning -->
<!-- Collect module .pages.js assets for Tailwind CSS scanning. The host's app.css must
carry the matching @source "./_scan/" for these to be scanned — it is relative to
Styles/, so the same line works in the monorepo and in a CLI scaffold (#290). -->
<Target
Name="CollectModuleAssets"
BeforeTargets="TailwindBuild"
Expand All @@ -69,6 +105,14 @@
DestinationFolder="$(MSBuildProjectDirectory)/Styles/_scan/"
SkipUnchangedFiles="true"
/>
<!-- Tracked here rather than in the evaluation-time ItemGroup above: these files
are staged by the Copy that just ran, so on the first build after installing a
packaged module the glob would otherwise expand to nothing and TailwindBuild
would be judged up to date. This target runs before TailwindBuild's
Inputs/Outputs check, so items added now are seen by it. -->
<ItemGroup>
<TailwindSourceFiles Include="$(MSBuildProjectDirectory)/Styles/_scan/**/*.js" />
</ItemGroup>
</Target>
<!-- Warn when Tailwind input exists but node_modules is missing -->
<Target
Expand All @@ -83,7 +127,7 @@
Name="TailwindBuild"
BeforeTargets="Build"
Condition="Exists('$(TailwindInput)') And Exists('$(RepoRoot)node_modules')"
Inputs="@(TailwindSourceFiles)"
Inputs="@(TailwindSourceFiles);@(TailwindExtraSourceFiles)"
Outputs="$(TailwindOutput)"
>
<Exec Command="&quot;$(TailwindCli)&quot; -i &quot;$(TailwindInput)&quot; -o &quot;$(TailwindOutput)&quot; --minify" />
Expand Down
19 changes: 0 additions & 19 deletions modules/Core/src/SimpleModule.Core/types.ts

This file was deleted.

10 changes: 0 additions & 10 deletions modules/Identity/src/SimpleModule.Identity/types.ts

This file was deleted.

Loading
Loading