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
10 changes: 10 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@
Pattern="*.mjs"
Priority="1" />
</ItemGroup>
<!-- Frontend build-integration paths consumed by SimpleModule.Hosting.targets.
The shipped targets default these to a consumer (NuGet) scaffold layout; here we
override them to the monorepo layout. Scoped to the monorepo via the packages/ check
so a Directory.Build.props copied into a scaffolded project (which has no packages/
directory) falls back to the consumer defaults. -->
<PropertyGroup Condition="Exists('$(MSBuildThisFileDirectory)packages')">
<SimpleModuleThemeCss>$(RepoRoot)packages/SimpleModule.Theme.Default/theme.css</SimpleModuleThemeCss>
<SimpleModuleModulesDir>$(RepoRoot)modules</SimpleModuleModulesDir>
<SimpleModuleRoutesOutput>$(RepoRoot)packages/SimpleModule.Client/src/routes.ts</SimpleModuleRoutesOutput>
</PropertyGroup>
<!-- .NET SDK analyzers only work on net5.0+ -->
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
<AnalysisLevel>latest-all</AnalysisLevel>
Expand Down
21 changes: 18 additions & 3 deletions cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public override int Execute(CommandContext context, NewProjectSettings settings)
solution
);

// The @simplemodule/* npm packages are not always published in lockstep with NuGet,
// so pin them to the latest published npm version rather than the framework version
// (otherwise `npm install` fails with notarget — see issue #219).
var npmVersion = NpmVersionResolver.ResolveVersion(frameworkVersion);

if (settings.DryRun)
{
var ops = PlanFiles(projectName, rootDir);
Expand All @@ -47,7 +52,7 @@ public override int Execute(CommandContext context, NewProjectSettings settings)
ctx =>
{
ctx.Status("Scaffolding project files...");
ScaffoldProject(projectName, rootDir, solution, frameworkVersion);
ScaffoldProject(projectName, rootDir, solution, frameworkVersion, npmVersion);
}
);

Expand All @@ -70,10 +75,15 @@ public static void ScaffoldProject(
string projectName,
string rootDir,
SolutionContext? solution,
string frameworkVersion
string frameworkVersion,
string? npmVersion = null
)
{
var projectTemplates = new ProjectTemplates(solution, frameworkVersion);
var projectTemplates = new ProjectTemplates(
solution,
frameworkVersion,
npmVersion ?? frameworkVersion
);
var moduleTemplates = new ModuleTemplates(solution);

const string moduleName = "Items";
Expand Down Expand Up @@ -155,6 +165,10 @@ string frameworkVersion
Path.Combine(hostDir, "wwwroot", "index.html"),
HostTemplates.IndexHtml()
);
File.WriteAllText(
Path.Combine(hostDir, "wwwroot", "favicon.svg"),
HostTemplates.FaviconSvg()
);
File.WriteAllText(Path.Combine(hostDir, "ClientApp", "app.tsx"), HostTemplates.AppTsx());
File.WriteAllText(
Path.Combine(hostDir, "ClientApp", "vite.config.ts"),
Expand Down Expand Up @@ -309,6 +323,7 @@ string rootDir
Plan(Path.Combine(hostDir, $"{projectName}.Host.csproj"));
Plan(Path.Combine(hostDir, "Program.cs"));
Plan(Path.Combine(hostDir, "wwwroot", "index.html"));
Plan(Path.Combine(hostDir, "wwwroot", "favicon.svg"));
Plan(Path.Combine(hostDir, "ClientApp", "app.tsx"));
Plan(Path.Combine(hostDir, "ClientApp", "vite.config.ts"));
Plan(Path.Combine(hostDir, "ClientApp", "vite.dev.config.ts"));
Expand Down
66 changes: 66 additions & 0 deletions cli/SimpleModule.Cli/Infrastructure/NpmVersionResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Text.Json;

namespace SimpleModule.Cli.Infrastructure;

/// <summary>
/// Resolves the latest <em>published</em> version of the <c>@simplemodule/*</c> npm packages.
/// The npm packages are not always published in lockstep with the NuGet framework version, so a
/// scaffold must not assume the framework (NuGet) version exists on npm — otherwise
/// <c>npm install</c> fails with <c>notarget</c> (see issue #219).
/// </summary>
public static class NpmVersionResolver
{
private static readonly Uri NpmRegistryUri = new(
"https://registry.npmjs.org/@simplemodule/client"
);

// Shared HttpClient avoids socket exhaustion from repeated instantiation.
private static readonly HttpClient SharedHttpClient = new()
{
Timeout = TimeSpan.FromSeconds(5),
};

/// <summary>
/// Resolves the npm version to pin the <c>@simplemodule/*</c> dependencies to.
/// Priority: npm registry <c>dist-tags.latest</c> &gt; <paramref name="fallbackVersion"/>.
/// </summary>
/// <param name="fallbackVersion">
/// Version to use when the registry can't be reached (typically the framework version).
/// </param>
public static string ResolveVersion(string fallbackVersion)
{
return QueryNpmLatestVersion() ?? fallbackVersion;
}

private static string? QueryNpmLatestVersion()
{
try
{
// Intentional sync-over-async: CLI runs single-threaded, no deadlock risk.
var json = SharedHttpClient.GetStringAsync(NpmRegistryUri).GetAwaiter().GetResult();
using var doc = JsonDocument.Parse(json);

if (
doc.RootElement.TryGetProperty("dist-tags", out var distTags)
&& distTags.TryGetProperty("latest", out var latest)
)
{
return latest.GetString();
}
}
catch (HttpRequestException)
{
// Network errors — fall through
}
catch (TaskCanceledException)
{
// Timeout — fall through
}
catch (JsonException)
{
// Parse errors — fall through
}

return null;
}
}
4 changes: 4 additions & 0 deletions cli/SimpleModule.Cli/SimpleModule.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
Include="..\..\template\SimpleModule.Host\wwwroot\index.html"
LogicalName="Templates.Host.wwwroot.index.html"
/>
<EmbeddedResource
Include="..\..\template\SimpleModule.Host\wwwroot\favicon.svg"
LogicalName="Templates.Host.wwwroot.favicon.svg"
/>
<EmbeddedResource
Include="..\..\template\SimpleModule.Host\ClientApp\app.tsx"
LogicalName="Templates.Host.ClientApp.app.tsx"
Expand Down
9 changes: 9 additions & 0 deletions cli/SimpleModule.Cli/Templates/HostTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ public static string IndexHtml()
return EmbeddedResourceReader.ReadTemplate("Templates.Host.wwwroot.index.html");
}

/// <summary>
/// Copy favicon.svg as-is. The host's index.html links <c>/favicon.svg</c> and
/// Program.cs maps <c>/favicon.ico</c> to it, so the scaffold must ship the file.
/// </summary>
public static string FaviconSvg()
{
return EmbeddedResourceReader.ReadTemplate("Templates.Host.wwwroot.favicon.svg");
}

/// <summary>
/// Copy app.tsx as-is.
/// </summary>
Expand Down
44 changes: 33 additions & 11 deletions cli/SimpleModule.Cli/Templates/ModuleTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ public string ContractsCsproj(string moduleName)
ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName)
),
$"SimpleModule.{moduleName}.Contracts"
$"SimpleModule.{moduleName}.Contracts",
$"{moduleName}.Contracts"
);
}

Expand All @@ -60,7 +61,8 @@ public string ModuleCsproj(string moduleName)
ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns)
),
$"SimpleModule.{moduleName}"
$"SimpleModule.{moduleName}",
moduleName
);
}

Expand Down Expand Up @@ -803,12 +805,25 @@ private static string ReplaceFrameworkProjectRefs(string csprojContent)

/// <summary>
/// Ensures the csproj content has explicit RootNamespace and AssemblyName set to the
/// expected SimpleModule.&lt;Module&gt; convention. This guards against the analyzer's SM0052
/// (assembly naming) and SM0053 (matching contracts assembly) checks when the csproj
/// filename and namespace prefix don't naturally agree.
/// expected convention. This guards against the analyzer's SM0052 (assembly naming) and
/// SM0053 (matching contracts assembly) checks when the csproj filename and namespace
/// prefix don't naturally agree.
/// </summary>
private static string EnsureAssemblyName(string csprojContent, string expectedName)
/// <param name="rootNamespace">The RootNamespace to insert when missing.</param>
/// <param name="assemblyName">
/// The AssemblyName to insert when missing. Defaults to <paramref name="rootNamespace"/>.
/// The module (RCL) project passes the bare module name here so its served static-web-asset
/// base path (<c>/_content/{AssemblyName}/</c>) matches the Vite pages bundle name, which is
/// derived from the project directory basename.
/// </param>
private static string EnsureAssemblyName(
string csprojContent,
string rootNamespace,
string? assemblyName = null
)
{
assemblyName ??= rootNamespace;

var hasAssembly = csprojContent.Contains("<AssemblyName>", StringComparison.Ordinal);
var hasRoot = csprojContent.Contains("<RootNamespace>", StringComparison.Ordinal);

Expand All @@ -833,12 +848,12 @@ private static string EnsureAssemblyName(string csprojContent, string expectedNa

if (!hasRoot)
{
toInsert.Add($"{indent}<RootNamespace>{expectedName}</RootNamespace>");
toInsert.Add($"{indent}<RootNamespace>{rootNamespace}</RootNamespace>");
}

if (!hasAssembly)
{
toInsert.Add($"{indent}<AssemblyName>{expectedName}</AssemblyName>");
toInsert.Add($"{indent}<AssemblyName>{assemblyName}</AssemblyName>");
}

lines.InsertRange(insertAt, toInsert);
Expand Down Expand Up @@ -967,7 +982,11 @@ private static string FallbackContractsCsproj(string moduleName) =>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>SimpleModule.{moduleName}.Contracts</RootNamespace>
<AssemblyName>SimpleModule.{moduleName}.Contracts</AssemblyName>
<!-- AssemblyName matches the directory basename and stays paired with the bare
module assembly: the source generator discovers a module's contract
implementations by looking for the module-assembly-name + ".Contracts"
assembly, so both must use the bare name. -->
<AssemblyName>{moduleName}.Contracts</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SimpleModule.Core" />
Expand All @@ -982,7 +1001,10 @@ private static string FallbackModuleCsproj(string moduleName) =>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>SimpleModule.{moduleName}</RootNamespace>
<AssemblyName>SimpleModule.{moduleName}</AssemblyName>
<!-- AssemblyName must equal the project directory basename so the RCL
serves wwwroot at /_content/{moduleName}/ and the Vite pages bundle
({moduleName}.pages.js, named from the directory basename) resolves. -->
<AssemblyName>{moduleName}</AssemblyName>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
Expand Down Expand Up @@ -1052,7 +1074,7 @@ private static string FallbackEventClass(string moduleName, string singularName)

namespace SimpleModule.{{moduleName}}.Contracts.Events;

public sealed record {{singularName}}CreatedEvent(int {{singularName}}Id) : IEvent;
public sealed record {{singularName}}CreatedEvent(int {{singularName}}Id) : DomainEvent;
""";

private static string FallbackModuleClass(string moduleName, string singularName) =>
Expand Down
20 changes: 15 additions & 5 deletions cli/SimpleModule.Cli/Templates/ProjectTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,21 @@ public sealed class ProjectTemplates
private const string BaseProjectName = "SimpleModule";
private readonly SolutionContext? _solution;
private readonly string _frameworkVersion;
private readonly string _npmVersion;

public ProjectTemplates(SolutionContext? solution, string frameworkVersion)
: this(solution, frameworkVersion, frameworkVersion) { }

/// <param name="npmVersion">
/// Version to pin the <c>@simplemodule/*</c> npm dependencies to. The npm packages are not
/// always published in lockstep with NuGet, so this may differ from
/// <paramref name="frameworkVersion"/> (see issue #219).
/// </param>
public ProjectTemplates(SolutionContext? solution, string frameworkVersion, string npmVersion)
{
_solution = solution;
_frameworkVersion = frameworkVersion;
_npmVersion = npmVersion;
}

public string Slnx(string projectName)
Expand Down Expand Up @@ -233,7 +243,7 @@ public string GlobalJson()

public string RootPackageJson(string projectName, string? frameworkPackagesPath)
{
return GenerateRootPackageJson(projectName, frameworkPackagesPath, _frameworkVersion);
return GenerateRootPackageJson(projectName, frameworkPackagesPath, _npmVersion);
}

public string BiomeJson()
Expand Down Expand Up @@ -689,7 +699,7 @@ private static string FallbackTestsSharedCsproj(string projectName) =>
private static string GenerateRootPackageJson(
string projectName,
string? frameworkPackagesPath,
string frameworkVersion
string npmVersion
)
{
// npm 'name' field must be lowercase; workspace glob uses actual project casing.
Expand All @@ -708,9 +718,9 @@ string frameworkVersion
}
else
{
clientDep = $"\"^{frameworkVersion}\"";
uiDep = $"\"^{frameworkVersion}\"";
themeDep = $"\"^{frameworkVersion}\"";
clientDep = $"\"^{npmVersion}\"";
uiDep = $"\"^{npmVersion}\"";
themeDep = $"\"^{npmVersion}\"";
}

return $$"""
Expand Down
31 changes: 28 additions & 3 deletions framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
<Project>
<!-- Resolve the npm workspace root. In the monorepo, RepoRoot is supplied by
Directory.Build.props. In a downstream consumer that references the
SimpleModule.Hosting NuGet package, RepoRoot is undefined, so fall back to the
nearest ancestor directory containing package.json (the npm workspace root where
node_modules is installed). -->
<PropertyGroup>
<RepoRoot Condition="'$(RepoRoot)' == ''"
>$([MSBuild]::EnsureTrailingSlash($([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', 'package.json'))))</RepoRoot
>
</PropertyGroup>
<!-- Frontend build-integration paths. Defaults target a consumer scaffold layout
(theme from the @simplemodule/theme-default npm package, modules under src/modules,
routes emitted into the host's ClientApp). The monorepo overrides these in its
Directory.Build.props to point at the in-repo packages/ and modules/ folders. -->
<PropertyGroup>
<SimpleModuleThemeCss Condition="'$(SimpleModuleThemeCss)' == ''"
>$(RepoRoot)node_modules/@simplemodule/theme-default/theme.css</SimpleModuleThemeCss
>
<SimpleModuleModulesDir Condition="'$(SimpleModuleModulesDir)' == ''"
>$(RepoRoot)src/modules</SimpleModuleModulesDir
>
<SimpleModuleRoutesOutput Condition="'$(SimpleModuleRoutesOutput)' == ''"
>$(MSBuildProjectDirectory)/ClientApp/routes.ts</SimpleModuleRoutesOutput
>
</PropertyGroup>
<!-- Build configuration -->
<PropertyGroup>
<TailwindCli Condition="'$(OS)' == 'Windows_NT'"
Expand All @@ -19,7 +44,7 @@
Include="$(MSBuildProjectDirectory)/Styles/**/*.css"
Exclude="$(MSBuildProjectDirectory)/Styles/_scan/**"
/>
<TailwindSourceFiles Include="$(RepoRoot)packages/SimpleModule.Theme.Default/theme.css" />
<TailwindSourceFiles Include="$(SimpleModuleThemeCss)" Condition="Exists('$(SimpleModuleThemeCss)')" />
</ItemGroup>
<!-- Incremental Vite: track ClientApp source files -->
<ItemGroup Condition="Exists('ClientApp/package.json') And (Exists('ClientApp/node_modules') Or Exists('$(RepoRoot)node_modules'))">
Expand Down Expand Up @@ -95,7 +120,7 @@
Outputs="$(ExtractDtoStamp)"
>
<Exec
Command="node &quot;$(RepoRoot)scripts/extract-ts-types.mjs&quot; &quot;$(IntermediateOutputPath)generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator&quot; &quot;$(RepoRoot)modules&quot;"
Command="node &quot;$(RepoRoot)scripts/extract-ts-types.mjs&quot; &quot;$(IntermediateOutputPath)generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator&quot; &quot;$(SimpleModuleModulesDir)&quot;"
WorkingDirectory="$(MSBuildProjectDirectory)"
/>
<Touch Files="$(ExtractDtoStamp)" AlwaysCreate="true" />
Expand All @@ -109,7 +134,7 @@
Outputs="$(ExtractRoutesStamp)"
>
<Exec
Command="node &quot;$(RepoRoot)scripts/extract-routes.mjs&quot; &quot;$(IntermediateOutputPath)generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator&quot; &quot;$(RepoRoot)packages/SimpleModule.Client/src/routes.ts&quot;"
Command="node &quot;$(RepoRoot)scripts/extract-routes.mjs&quot; &quot;$(IntermediateOutputPath)generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator&quot; &quot;$(SimpleModuleRoutesOutput)&quot;"
WorkingDirectory="$(MSBuildProjectDirectory)"
/>
<Touch Files="$(ExtractRoutesStamp)" AlwaysCreate="true" />
Expand Down
Loading
Loading