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
2 changes: 1 addition & 1 deletion cli/SimpleModule.Cli/Commands/New/NewFeatureCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public override int Execute(CommandContext context, NewFeatureSettings settings)
var httpMethod = settings.ResolveHttpMethod();
var route = settings.ResolveRoute();
var includeValidator = settings.ResolveIncludeValidator();
var singularName = ModuleTemplates.GetSingularName(moduleName);
var singularName = ModuleTemplates.GetEntityName(moduleName);

var templates = new FeatureTemplates(solution);
var ops = new List<(string Path, FileAction Action)>();
Expand Down
2 changes: 1 addition & 1 deletion cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public sealed class NewModuleCommand : Command<NewModuleSettings>
public override int Execute(CommandContext context, NewModuleSettings settings)
{
var moduleName = settings.ResolveName();
var singularName = ModuleTemplates.GetSingularName(moduleName);
var singularName = ModuleTemplates.GetEntityName(moduleName);

var solution = SolutionContext.Discover();
if (solution is null)
Expand Down
6 changes: 3 additions & 3 deletions cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ string frameworkVersion
var moduleTemplates = new ModuleTemplates(solution);

const string moduleName = "Items";
var singularName = ModuleTemplates.GetSingularName(moduleName);
var singularName = ModuleTemplates.GetEntityName(moduleName);

var hostDir = Path.Combine(rootDir, "src", $"{projectName}.Host");
var modulesDir = Path.Combine(rootDir, "src", "modules");
Expand Down Expand Up @@ -121,7 +121,7 @@ string frameworkVersion
)
);
File.WriteAllText(Path.Combine(rootDir, "biome.json"), projectTemplates.BiomeJson());
File.WriteAllText(Path.Combine(rootDir, "tsconfig.json"), projectTemplates.TsconfigJson());
File.WriteAllText(Path.Combine(rootDir, "tsconfig.json"), ProjectTemplates.TsconfigJson());
var editorConfig = projectTemplates.EditorConfig();
if (!string.IsNullOrEmpty(editorConfig))
{
Expand Down Expand Up @@ -278,7 +278,7 @@ string rootDir
var testsSharedDir = Path.Combine(rootDir, "tests", $"{projectName}.Tests.Shared");

const string moduleName = "Items";
var singularName = ModuleTemplates.GetSingularName(moduleName);
var singularName = ModuleTemplates.GetEntityName(moduleName);

// Root config files
Plan(Path.Combine(rootDir, $"{projectName}.slnx"));
Expand Down
2 changes: 1 addition & 1 deletion cli/SimpleModule.Cli/Templates/FeatureTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public FeatureTemplates(SolutionContext solution)
{
_solution = solution;
_refModule = solution.ExistingModules.Count > 0 ? solution.ExistingModules[0] : null;
_refSingular = _refModule is not null ? ModuleTemplates.GetSingularName(_refModule) : null;
_refSingular = _refModule is not null ? ModuleTemplates.GetEntityName(_refModule) : null;
}

public string Endpoint(
Expand Down
39 changes: 35 additions & 4 deletions cli/SimpleModule.Cli/Templates/HostTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,47 @@ public static string ClientAppPackageJson(string projectName)
/// Return a clean Styles/app.css with tailwindcss import, theme import,
/// and @source directives for modules. Strip _scan/ import if present.
/// </summary>
/// <remarks>
/// The embedded template lives in <c>template/SimpleModule.Host/Styles/</c> and uses
/// monorepo-relative paths (<c>../../../packages/...</c>, <c>../../../modules/...</c>).
/// In a scaffolded project the layout is <c>src/&lt;Project&gt;.Host/Styles/</c>, so:
/// <list type="bullet">
/// <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>
/// </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));

// Replace module source paths for new project structure (template/ → src/)
var result = string.Join(Environment.NewLine, lines);
result = result.Replace("../../modules/", "../../../modules/", StringComparison.Ordinal);
var result = new List<string>(lines.Count);
foreach (var line in lines)
{
var rewritten = line.Replace(
"../../../packages/SimpleModule.Theme.Default/theme.css",
"../../../node_modules/@simplemodule/theme-default/theme.css",
StringComparison.Ordinal
)
.Replace(
"../../../packages/SimpleModule.UI/",
"../../../node_modules/@simplemodule/ui/",
StringComparison.Ordinal
)
.Replace(
"../../../packages/SimpleModule.Client/",
"../../../node_modules/@simplemodule/client/",
StringComparison.Ordinal
)
.Replace("../../../modules/", "../../modules/", StringComparison.Ordinal);

result.Add(rewritten);
}

return result;
return string.Join(Environment.NewLine, result);
}

/// <summary>
Expand Down
108 changes: 96 additions & 12 deletions cli/SimpleModule.Cli/Templates/ModuleTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public ModuleTemplates(SolutionContext? solution)
solution is not null && solution.ExistingModules.Count > 0
? solution.ExistingModules[0]
: null;
_refSingular = _refModule is not null ? GetSingularName(_refModule) : null;
_refSingular = _refModule is not null ? GetEntityName(_refModule) : null;
_otherModuleNames =
_refModule is not null && solution is not null
? solution
Expand All @@ -34,11 +34,14 @@ public string ContractsCsproj(string moduleName)
var refPath = RefContractsPath($"{_refModule}.Contracts.csproj");
if (refPath is null)
{
return FallbackContractsCsproj();
return FallbackContractsCsproj(moduleName);
}

return ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName)
return EnsureAssemblyName(
ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName)
),
$"SimpleModule.{moduleName}.Contracts"
);
}

Expand All @@ -53,8 +56,11 @@ public string ModuleCsproj(string moduleName)
// Strip references to other modules and non-essential packages
var stripPatterns = _otherModuleNames.Select(m => m).Append("Bogus").ToList();

return ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns)
return EnsureAssemblyName(
ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns)
),
$"SimpleModule.{moduleName}"
);
}

Expand All @@ -67,8 +73,11 @@ public string TestCsproj(string moduleName)
}

var stripPatterns = _otherModuleNames.ToList();
return ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns)
return EnsureAssemblyName(
ReplaceFrameworkProjectRefs(
TemplateExtractor.TransformCsproj(refPath, _refModule!, moduleName, stripPatterns)
),
$"SimpleModule.{moduleName}.Tests"
);
}

Expand Down Expand Up @@ -459,7 +468,7 @@ public string ServiceClass(string moduleName, string singularName)
// Find the class declaration and simplify constructor params
// Keep only the DbContext param, remove cross-module and infrastructure deps
var crossModuleTypes = _otherModuleNames
.Select(m => $"I{GetSingularName(m)}")
.Select(m => $"I{GetEntityName(m)}")
.Append("IMessageBus")
.Append("ILogger<")
.ToList();
Expand Down Expand Up @@ -689,6 +698,19 @@ public static string GetSingularName(string pluralName)
return pluralName;
}

/// <summary>
/// Derives the entity type name for a module. Normally this is the singular form,
/// but when the singular equals the module name (e.g. "PageBuilder", "Marketplace"),
/// the entity would collide with the module's namespace, so an "Item" suffix is added.
/// </summary>
public static string GetEntityName(string moduleName)
{
var singular = GetSingularName(moduleName);
return string.Equals(singular, moduleName, StringComparison.Ordinal)
? $"{moduleName}Item"
: singular;
}

// ── Helpers ─────────────────────────────────────────────────────

private string? RefContractsPath(string relativePath)
Expand Down Expand Up @@ -779,6 +801,50 @@ private static string ReplaceFrameworkProjectRefs(string csprojContent)
return string.Join(Environment.NewLine, TemplateExtractor.CollapseBlankLines(result));
}

/// <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.
/// </summary>
private static string EnsureAssemblyName(string csprojContent, string expectedName)
{
var hasAssembly = csprojContent.Contains("<AssemblyName>", StringComparison.Ordinal);
var hasRoot = csprojContent.Contains("<RootNamespace>", StringComparison.Ordinal);

if (hasAssembly && hasRoot)
{
return csprojContent;
}

var lines = csprojContent.Split(["\r\n", "\n"], StringSplitOptions.None).ToList();
var firstPropGroupIdx = lines.FindIndex(l =>
l.TrimStart().StartsWith("<PropertyGroup", StringComparison.Ordinal)
);

if (firstPropGroupIdx < 0)
{
return csprojContent;
}

var indent = " ";
var insertAt = firstPropGroupIdx + 1;
var toInsert = new List<string>();

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

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

lines.InsertRange(insertAt, toInsert);
return string.Join(Environment.NewLine, lines);
}

private List<string> OtherModuleStripPatterns()
{
return _otherModuleNames.Select(m => $"SimpleModule.{m}").ToList();
Expand Down Expand Up @@ -871,8 +937,20 @@ private static int CountBraces(string line)
public static string TsconfigJson() =>
"""
{
"extends": "@simplemodule/tsconfig/base",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"paths": {
"@/*": ["./*"]
}
Expand All @@ -882,12 +960,14 @@ public static string TsconfigJson() =>

// ── Fallback templates (when no reference module exists) ────────

private static string FallbackContractsCsproj() =>
"""
private static string FallbackContractsCsproj(string moduleName) =>
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>SimpleModule.{moduleName}.Contracts</RootNamespace>
<AssemblyName>SimpleModule.{moduleName}.Contracts</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SimpleModule.Core" />
Expand All @@ -901,6 +981,8 @@ private static string FallbackModuleCsproj(string moduleName) =>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>SimpleModule.{moduleName}</RootNamespace>
<AssemblyName>SimpleModule.{moduleName}</AssemblyName>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
Expand All @@ -918,6 +1000,8 @@ private static string FallbackTestCsproj(string moduleName) =>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<RootNamespace>SimpleModule.{moduleName}.Tests</RootNamespace>
<AssemblyName>SimpleModule.{moduleName}.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
Expand Down
46 changes: 9 additions & 37 deletions cli/SimpleModule.Cli/Templates/ProjectTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,39 +261,12 @@ public string BiomeJson()
return content;
}

public string TsconfigJson()
public static string TsconfigJson()
{
if (_solution is null)
{
return FallbackTsconfigJson();
}

var path = Path.Combine(_solution.RootPath, "tsconfig.json");
if (!File.Exists(path))
{
return FallbackTsconfigJson();
}

var content = File.ReadAllText(path);

// Remove the SimpleModule.UI registry templates exclude entry
content = content.Replace(
", \"src/SimpleModule.UI/registry/templates\"",
"",
StringComparison.Ordinal
);
content = content.Replace(
"\"src/SimpleModule.UI/registry/templates\", ",
"",
StringComparison.Ordinal
);
content = content.Replace(
"\"src/SimpleModule.UI/registry/templates\"",
"",
StringComparison.Ordinal
);

return content;
// The root tsconfig in the monorepo extends "@simplemodule/tsconfig/base", which
// isn't published to npm. Always emit a self-contained tsconfig for scaffolded
// projects so npm install + tsc work without that package.
return FallbackTsconfigJson();
}

public string EditorConfig()
Expand Down Expand Up @@ -713,22 +686,19 @@ string frameworkVersion
string clientDep;
string uiDep;
string themeDep;
string tsconfigDep;

if (frameworkPackagesPath is not null)
{
var pkgPath = frameworkPackagesPath.Replace('\\', '/');
clientDep = $"\"file:{pkgPath}/SimpleModule.Client\"";
uiDep = $"\"file:{pkgPath}/SimpleModule.UI\"";
themeDep = $"\"file:{pkgPath}/SimpleModule.Theme.Default\"";
tsconfigDep = $"\"file:{pkgPath}/SimpleModule.TsConfig\"";
}
else
{
clientDep = $"\"^{frameworkVersion}\"";
uiDep = $"\"^{frameworkVersion}\"";
themeDep = $"\"^{frameworkVersion}\"";
tsconfigDep = $"\"^{frameworkVersion}\"";
}

return $$"""
Expand All @@ -750,6 +720,7 @@ string frameworkVersion
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
"@tailwindcss/cli": "^4.2.2",
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
Expand All @@ -763,7 +734,6 @@ string frameworkVersion
"@simplemodule/client": {{clientDep}},
"@simplemodule/ui": {{uiDep}},
"@simplemodule/theme-default": {{themeDep}},
"@simplemodule/tsconfig": {{tsconfigDep}},
"esbuild": "^0.27.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down Expand Up @@ -815,6 +785,7 @@ private static string FallbackBiomeJson() =>
private static string FallbackTsconfigJson() =>
"""
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
Expand All @@ -826,7 +797,8 @@ private static string FallbackTsconfigJson() =>
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
"noEmit": true,
"allowImportingTsExtensions": true
},
"exclude": ["node_modules", "**/wwwroot/**"]
}
Expand Down
Loading