diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 62e16d8..15a824a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -49,7 +49,18 @@ "Bash(\"C:\\Users\\stef\\source\\repos\\MvcFrontendKit\\src\\MvcFrontendKit\\runtimes\\win-x64\\native\\sass.bat\" \"C:\\Users\\stef\\source\\repos\\TestKit\\wwwroot\\css\\components\\notification.scss\" \"C:\\Users\\stef\\source\\repos\\TestKit\\wwwroot\\css\\components\\notification.css\" --no-source-map --no-charset)", "Bash(\"C:\\Users\\stef\\source\\repos\\MvcFrontendKit\\src\\MvcFrontendKit\\runtimes\\win-x64\\native\\sass.bat\" --help)", "Bash(\"C:\\Users\\stef\\source\\repos\\MvcFrontendKit\\src\\MvcFrontendKit\\runtimes\\win-x64\\native\\sass.bat\" \"C:\\Users\\stef\\source\\repos\\TestKit\\wwwroot\\css\\site.scss:C:\\Users\\stef\\source\\repos\\TestKit\\wwwroot\\css\\site.css\" \"C:\\Users\\stef\\source\\repos\\TestKit\\wwwroot\\css\\components\\notification.scss:C:\\Users\\stef\\source\\repos\\TestKit\\wwwroot\\css\\components\\notification.css\" --no-source-map --no-charset)", - "Bash(\"C:\\Users\\stef\\source\\repos\\MvcFrontendKit\\src\\MvcFrontendKit\\runtimes\\win-x64\\native\\sass.bat\" \"wwwroot/css/site.scss:wwwroot/css/test1.css\" \"wwwroot/css/Home/Index.scss:wwwroot/css/Home/test2.css\" --no-source-map --no-charset)" + "Bash(\"C:\\Users\\stef\\source\\repos\\MvcFrontendKit\\src\\MvcFrontendKit\\runtimes\\win-x64\\native\\sass.bat\" \"wwwroot/css/site.scss:wwwroot/css/test1.css\" \"wwwroot/css/Home/Index.scss:wwwroot/css/Home/test2.css\" --no-source-map --no-charset)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(dotnet publish:*)", + "Bash(curl:*)", + "Bash(git restore:*)", + "Bash(dotnet tool:*)", + "Bash(dotnet-frontend build:*)", + "Bash(unzip:*)", + "Bash(dotnet frontend dev:*)", + "Bash(timeout 5 dotnet frontend:*)", + "Bash(timeout 8 dotnet frontend dev:*)", + "Bash(timeout 5 dotnet run:*)" ], "deny": [], "ask": [] diff --git a/README.md b/README.md index e54b532..6f4b276 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ dotnet tool install MvcFrontendKit.Cli dotnet frontend init ``` +This also patches your `.csproj` to include `frontend.config.yaml` in `dotnet publish` output. If the `.csproj` cannot be found or patched automatically, the command prints instructions for adding the entry manually. + If you don't have the CLI installed, you can copy the template from the [SPEC.md](SPEC.md#32-core-schema-overview) or let the MSBuild target auto-generate a default config on first build. ### 4. Register services in Program.cs diff --git a/SPEC.md b/SPEC.md index 1a87b6a..7a0127c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -770,9 +770,14 @@ This helper is optional and intended for development troubleshooting. It can saf - Creates `frontend.config.yaml` if missing, using a template identical in structure and comments to Section 3.2. - Does not overwrite existing config unless `--force` is specified. +- After writing the YAML file, automatically patches the `.csproj` to include `frontend.config.yaml` as a `` item with `CopyToPublishDirectory="PreserveNewest"`, ensuring it is included in `dotnet publish` output. +- Finds the `.csproj` by scanning the current directory; skips patching if zero or more than one `.csproj` is found. +- Checks for an existing Content item (case-insensitive) before adding; running `init --force` twice does not duplicate the item. +- If `.csproj` patching fails for any reason, prints a warning with manual XML snippet instructions (does not fail the init command). ### 10.2 `dotnet frontend check [--verbose]` +- Validates `.csproj` includes `frontend.config.yaml` as a Content item for publish output. Reports a warning (not an error) if missing, with fix instructions. - Loads YAML config and validates: - `global.js` / `global.css` paths. - `views.overrides[*].js` / `css` paths. diff --git a/src/MvcFrontendKit.Cli/Commands/CheckCommand.cs b/src/MvcFrontendKit.Cli/Commands/CheckCommand.cs index 1f4faed..5458307 100644 --- a/src/MvcFrontendKit.Cli/Commands/CheckCommand.cs +++ b/src/MvcFrontendKit.Cli/Commands/CheckCommand.cs @@ -1,4 +1,6 @@ using System.Text.RegularExpressions; +using System.Xml.Linq; +using MvcFrontendKit.Cli.Helpers; using MvcFrontendKit.Configuration; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -68,6 +70,7 @@ public static int Execute(bool verbose, string? viewKey = null, bool checkAll = } // Standard check + warnings += CheckProjectFile(verbose); errors += CheckGlobalAssets(config, verbose, validateImports); errors += CheckViewOverrides(config, verbose, validateImports); errors += CheckComponents(config, verbose, validateImports); @@ -101,6 +104,54 @@ public static int Execute(bool verbose, string? viewKey = null, bool checkAll = } } + /// + /// Checks whether the .csproj includes frontend.config.yaml as a Content item for publish output. + /// Returns 1 if warning issued, 0 otherwise. + /// + private static int CheckProjectFile(bool verbose) + { + var csprojPath = CsprojHelper.FindCsprojFile(Directory.GetCurrentDirectory()); + + if (csprojPath == null) + { + if (verbose) + { + Console.WriteLine("Project File:"); + Console.WriteLine(" ⚠ No .csproj found (skipped)"); + Console.WriteLine(); + } + return 0; + } + + Console.WriteLine("Project File:"); + + try + { + var doc = XDocument.Load(csprojPath); + + if (CsprojHelper.HasConfigContentItem(doc)) + { + Console.WriteLine($" ✓ {Path.GetFileName(csprojPath)} includes frontend.config.yaml for publish"); + } + else + { + Console.WriteLine($" ⚠ {Path.GetFileName(csprojPath)} does not include frontend.config.yaml for publish"); + Console.WriteLine(" Config file will not be included in 'dotnet publish' output."); + Console.WriteLine(" Fix: Run 'dotnet frontend init --force' or add manually:"); + Console.WriteLine(CsprojHelper.GetManualXmlSnippet()); + Console.WriteLine(); + return 1; + } + } + catch (Exception ex) + { + Console.WriteLine($" ⚠ Could not read {Path.GetFileName(csprojPath)}: {ex.Message}"); + } + + Console.WriteLine(); + return 0; + } + /// /// Checks a specific view by key and shows detailed diagnostic information. /// diff --git a/src/MvcFrontendKit.Cli/Commands/InitCommand.cs b/src/MvcFrontendKit.Cli/Commands/InitCommand.cs index 10f9802..761e251 100644 --- a/src/MvcFrontendKit.Cli/Commands/InitCommand.cs +++ b/src/MvcFrontendKit.Cli/Commands/InitCommand.cs @@ -1,4 +1,6 @@ using System.Reflection; +using System.Xml.Linq; +using MvcFrontendKit.Cli.Helpers; namespace MvcFrontendKit.Cli.Commands; @@ -28,6 +30,9 @@ public static int Execute(bool force) File.WriteAllText(configPath, template); Console.WriteLine($"✓ Created frontend.config.yaml at: {configPath}"); + + PatchCsproj(Directory.GetCurrentDirectory()); + Console.WriteLine(); Console.WriteLine("Next steps:"); Console.WriteLine(" 1. Edit frontend.config.yaml to match your project structure"); @@ -44,6 +49,40 @@ public static int Execute(bool force) } } + private static void PatchCsproj(string directory) + { + try + { + var csprojPath = CsprojHelper.FindCsprojFile(directory); + if (csprojPath == null) + { + Console.WriteLine(); + Console.WriteLine("⚠ Could not find a .csproj file to patch."); + Console.WriteLine(" Add the following to your .csproj manually to include the config in publish output:"); + Console.WriteLine(CsprojHelper.GetManualXmlSnippet()); + return; + } + + var doc = XDocument.Load(csprojPath); + + if (!CsprojHelper.AddConfigContentItem(doc)) + { + Console.WriteLine($"✓ {Path.GetFileName(csprojPath)} already includes frontend.config.yaml"); + return; + } + + doc.Save(csprojPath); + Console.WriteLine($"✓ Added frontend.config.yaml to {Path.GetFileName(csprojPath)}"); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.WriteLine($"⚠ Could not patch .csproj: {ex.Message}"); + Console.WriteLine(" Add the following to your .csproj manually:"); + Console.WriteLine(CsprojHelper.GetManualXmlSnippet()); + } + } + private static string? GetEmbeddedTemplate() { var assembly = Assembly.GetExecutingAssembly(); diff --git a/src/MvcFrontendKit.Cli/Helpers/CsprojHelper.cs b/src/MvcFrontendKit.Cli/Helpers/CsprojHelper.cs new file mode 100644 index 0000000..5626f79 --- /dev/null +++ b/src/MvcFrontendKit.Cli/Helpers/CsprojHelper.cs @@ -0,0 +1,79 @@ +using System.Xml.Linq; + +namespace MvcFrontendKit.Cli.Helpers; + +public static class CsprojHelper +{ + private const string ConfigFileName = "frontend.config.yaml"; + + /// + /// Finds a single .csproj file in the given directory. + /// Returns null if zero or more than one .csproj is found. + /// + public static string? FindCsprojFile(string directory) + { + var csprojFiles = Directory.GetFiles(directory, "*.csproj"); + + if (csprojFiles.Length == 0) + { + return null; + } + + if (csprojFiles.Length > 1) + { + Console.WriteLine($" Multiple .csproj files found in {directory}. Skipping .csproj patching."); + return null; + } + + return csprojFiles[0]; + } + + /// + /// Checks whether the XDocument already contains a Content item for frontend.config.yaml. + /// Case-insensitive comparison on the Include attribute. + /// + public static bool HasConfigContentItem(XDocument doc) + { + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + + return doc.Descendants(ns + "Content") + .Any(el => string.Equals( + el.Attribute("Include")?.Value, + ConfigFileName, + StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Adds a Content item for frontend.config.yaml with CopyToPublishDirectory="PreserveNewest". + /// Returns true if the item was added, false if it already exists. + /// + public static bool AddConfigContentItem(XDocument doc) + { + if (HasConfigContentItem(doc)) + { + return false; + } + + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + + var itemGroup = new XElement(ns + "ItemGroup", + new XElement(ns + "Content", + new XAttribute("Include", ConfigFileName), + new XAttribute("CopyToPublishDirectory", "PreserveNewest"))); + + doc.Root!.Add(itemGroup); + return true; + } + + /// + /// Returns the XML snippet for manual .csproj patching instructions. + /// + public static string GetManualXmlSnippet() + { + return """ + + + + """; + } +} diff --git a/src/MvcFrontendKit.Cli/MvcFrontendKit.Cli.csproj b/src/MvcFrontendKit.Cli/MvcFrontendKit.Cli.csproj index cedac42..e6c1e2d 100644 --- a/src/MvcFrontendKit.Cli/MvcFrontendKit.Cli.csproj +++ b/src/MvcFrontendKit.Cli/MvcFrontendKit.Cli.csproj @@ -7,7 +7,7 @@ enable true dotnet-frontend - 1.0.0 + 1.1.0 stef-k CLI tool for MvcFrontendKit - compile TypeScript/SCSS during development, validate config and check assets. Uses esbuild and Dart Sass. aspnetcore;mvc;razor;bundling;esbuild;sass;scss;typescript;frontend;cli diff --git a/src/MvcFrontendKit/MvcFrontendKit.csproj b/src/MvcFrontendKit/MvcFrontendKit.csproj index 156b7bb..6e087fd 100644 --- a/src/MvcFrontendKit/MvcFrontendKit.csproj +++ b/src/MvcFrontendKit/MvcFrontendKit.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - 1.0.0 + 1.1.0 stef-k Node-free frontend bundling toolkit for ASP.NET Core MVC / Razor applications. Uses esbuild for JS/TS bundling and Dart Sass for SCSS compilation. aspnetcore;mvc;razor;bundling;esbuild;sass;scss;typescript;frontend diff --git a/src/MvcFrontendKit/Services/FrontendConfigProvider.cs b/src/MvcFrontendKit/Services/FrontendConfigProvider.cs index df7672a..6d08be6 100644 --- a/src/MvcFrontendKit/Services/FrontendConfigProvider.cs +++ b/src/MvcFrontendKit/Services/FrontendConfigProvider.cs @@ -51,7 +51,7 @@ private FrontendConfig LoadConfig() if (!File.Exists(configPath)) { - _logger.LogWarning( + _logger.LogDebug( "Frontend config file not found at {ConfigPath}. Using default configuration. " + "Run 'dotnet frontend init' to generate a config file.", configPath); diff --git a/tests/MvcFrontendKit.Tests/CsprojHelperTests.cs b/tests/MvcFrontendKit.Tests/CsprojHelperTests.cs new file mode 100644 index 0000000..201b7a4 --- /dev/null +++ b/tests/MvcFrontendKit.Tests/CsprojHelperTests.cs @@ -0,0 +1,249 @@ +using System.Xml.Linq; +using MvcFrontendKit.Cli.Helpers; + +namespace MvcFrontendKit.Tests; + +public class CsprojHelperTests : IDisposable +{ + private readonly string _tempDir; + + public CsprojHelperTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"MvcFrontendKit_CsprojHelper_{Guid.NewGuid()}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } + + #region FindCsprojFile Tests + + [Fact] + public void FindCsprojFile_NoCsproj_ReturnsNull() + { + var result = CsprojHelper.FindCsprojFile(_tempDir); + + Assert.Null(result); + } + + [Fact] + public void FindCsprojFile_SingleCsproj_ReturnsPath() + { + var csprojPath = Path.Combine(_tempDir, "MyApp.csproj"); + File.WriteAllText(csprojPath, ""); + + var result = CsprojHelper.FindCsprojFile(_tempDir); + + Assert.Equal(csprojPath, result); + } + + [Fact] + public void FindCsprojFile_MultipleCsproj_ReturnsNull() + { + File.WriteAllText(Path.Combine(_tempDir, "App1.csproj"), ""); + File.WriteAllText(Path.Combine(_tempDir, "App2.csproj"), ""); + + var result = CsprojHelper.FindCsprojFile(_tempDir); + + Assert.Null(result); + } + + #endregion + + #region HasConfigContentItem Tests + + [Fact] + public void HasConfigContentItem_NotPresent_ReturnsFalse() + { + var doc = XDocument.Parse(""" + + + net10.0 + + + """); + + Assert.False(CsprojHelper.HasConfigContentItem(doc)); + } + + [Fact] + public void HasConfigContentItem_Present_ReturnsTrue() + { + var doc = XDocument.Parse(""" + + + + + + """); + + Assert.True(CsprojHelper.HasConfigContentItem(doc)); + } + + [Fact] + public void HasConfigContentItem_CaseInsensitive_ReturnsTrue() + { + var doc = XDocument.Parse(""" + + + + + + """); + + Assert.True(CsprojHelper.HasConfigContentItem(doc)); + } + + #endregion + + #region AddConfigContentItem Tests + + [Fact] + public void AddConfigContentItem_AddsNewItemGroup() + { + var doc = XDocument.Parse(""" + + + net10.0 + + + """); + + var result = CsprojHelper.AddConfigContentItem(doc); + + Assert.True(result); + Assert.True(CsprojHelper.HasConfigContentItem(doc)); + + var contentEl = doc.Descendants("Content").First(); + Assert.Equal("frontend.config.yaml", contentEl.Attribute("Include")?.Value); + Assert.Equal("PreserveNewest", contentEl.Attribute("CopyToPublishDirectory")?.Value); + } + + [Fact] + public void AddConfigContentItem_AlreadyExists_ReturnsFalse() + { + var doc = XDocument.Parse(""" + + + + + + """); + + var result = CsprojHelper.AddConfigContentItem(doc); + + Assert.False(result); + } + + [Fact] + public void AddConfigContentItem_Idempotent_NoDuplicates() + { + var doc = XDocument.Parse(""" + + + net10.0 + + + """); + + CsprojHelper.AddConfigContentItem(doc); + CsprojHelper.AddConfigContentItem(doc); + + var contentElements = doc.Descendants("Content") + .Where(el => string.Equals( + el.Attribute("Include")?.Value, + "frontend.config.yaml", + StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Assert.Single(contentElements); + } + + [Fact] + public void AddConfigContentItem_PreservesExistingElements() + { + var doc = XDocument.Parse(""" + + + net10.0 + + + + + + """); + + CsprojHelper.AddConfigContentItem(doc); + + // Original elements still present + Assert.Single(doc.Descendants("PackageReference")); + Assert.Single(doc.Descendants("PropertyGroup")); + + // New Content item added + Assert.True(CsprojHelper.HasConfigContentItem(doc)); + } + + #endregion + + #region GetManualXmlSnippet Tests + + [Fact] + public void GetManualXmlSnippet_ContainsRequiredElements() + { + var snippet = CsprojHelper.GetManualXmlSnippet(); + + Assert.Contains("frontend.config.yaml", snippet); + Assert.Contains("CopyToPublishDirectory", snippet); + Assert.Contains("PreserveNewest", snippet); + Assert.Contains("", snippet); + } + + #endregion + + #region End-to-End Tests + + [Fact] + public void EndToEnd_FindParsePatchSaveReload() + { + // Arrange: write a realistic .csproj + var csprojPath = Path.Combine(_tempDir, "MyWebApp.csproj"); + File.WriteAllText(csprojPath, """ + + + net10.0 + enable + + + + + + """); + + // Act: find, parse, patch, save + var foundPath = CsprojHelper.FindCsprojFile(_tempDir); + Assert.NotNull(foundPath); + + var doc = XDocument.Load(foundPath); + Assert.False(CsprojHelper.HasConfigContentItem(doc)); + + var added = CsprojHelper.AddConfigContentItem(doc); + Assert.True(added); + + doc.Save(foundPath); + + // Verify: reload and check + var reloaded = XDocument.Load(foundPath); + Assert.True(CsprojHelper.HasConfigContentItem(reloaded)); + + // Verify idempotent on reload + Assert.False(CsprojHelper.AddConfigContentItem(reloaded)); + } + + #endregion +}